Editor/Handles/ArchHandles.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

public static class ArchHandles {
	public static bool Draw( ArchTool tool, ArchSelection picked ) {
		ArchShapeHandles.BeginFrame();

		if ( !Gizmo.IsLeftMouseDown ) {
			heldPivots.Clear();
		}

		if ( picked?.Item is null ) {
			return false;
		}

		if ( tool.LayerTree.IsLocked( picked.Item ) ) {
			return false;
		}

		var node = tool.LayerTree.Find( picked.Item );

		if ( node?.Ref is { } layer && tool.LayerHidden( layer.ItemId ) ) {
			return false;
		}

		var kind = node?.Kind ?? ArchKinds.Load().Claiming( picked.Item )?.Kind ?? default;

		if ( ArchHandlers.Load().For( kind ) is { } handler ) {
			return handler.Draw( tool, picked );
		}

		return picked.Item switch {
			ArchSiteAssembly group => Assembly( tool, group ),
			ArchBuilding building => Blueprint( tool, building ),
			ArchRoofPart roof => Roof( tool, roof, picked.Building ),
			ArchPlatformPart platform => Platform( tool, platform ),
			ArchBeamPart beam => Beam( tool, beam ),
			ArchCutPart cut => Cut( tool, cut ),
			ArchRoom room => Room( tool, room ),
			ArchWall wall => Wall( tool, wall, picked.Room ),
			ArchOpening opening => Opening( tool, opening, picked.Room ),
			ArchWallModPart modifier => Modifier( tool, modifier, picked.Room ),
			ArchTrimPart trim => Trim( tool, trim ),
			ArchDownpipePart pipe => Downpipe( tool, pipe ),
			ArchLadderPart ladder => Ladder( tool, ladder ),
			ArchBalconyPart balcony => Balcony( tool, balcony ),
			ArchApproachPart approach when approach.IsSpline => ArchShapeHandles.Nodes( tool, ArchGesture.On( ArchKind.Approach, approach.Id ), approach.Nodes ),
			ArchApproachPart approach => Approach( tool, approach ),
			ArchFencePart fence => ArchShapeHandles.Nodes( tool, ArchGesture.On( ArchKind.Fence, fence.Id ), fence.Nodes ),
			ArchRoadPart road => ArchShapeHandles.Nodes( tool, ArchGesture.On( ArchKind.Road, road.Id ), road.Nodes ),
			_ => false
		};
	}

	public static bool Bounds( ArchBuilding building, out Vector2 min, out Vector2 max ) {
		var low = new Vector2( float.MaxValue, float.MaxValue );
		var high = new Vector2( float.MinValue, float.MinValue );
		var found = false;

		foreach ( var room in building.Rooms ) {
			var loop = ArchFloorGen.Footprint( room );

			if ( loop.Count < 3 ) {
				continue;
			}

			ArchFootprint.Bounds( loop, out var roomMin, out var roomMax );

			low = Vector2.Min( low, roomMin );
			high = Vector2.Max( high, roomMax );
			found = true;
		}

		min = low;
		max = high;

		return found && high.x - low.x > 1f && high.y - low.y > 1f;
	}

	// Pivot is held for the whole gesture so an L's shifting bounds centre doesn't walk the building sideways.
	static readonly Dictionary<int, Vector2> heldPivots = new();

	public static bool Pivot( ArchBuilding building, out Vector2 about ) {
		about = Vector2.Zero;

		if ( Gizmo.IsLeftMouseDown && building is not null && heldPivots.TryGetValue( building.Id, out var held ) ) {
			about = held;

			return true;
		}

		if ( building is null || !Bounds( building, out var min, out var max ) ) {
			return false;
		}

		about = (min + max) * 0.5f;

		if ( Gizmo.IsLeftMouseDown ) {
			heldPivots[building.Id] = about;
		}

		return true;
	}

	// Outline handles only on free decks — room-clipped decks are moved via the shell.
	static bool Roof( ArchTool tool, ArchRoofPart roof, ArchBuilding building ) {
		var lift = building is null ? 0f : ArchAsks.Lift( tool.Plan, building, tool.Kit );
		var height = roof.BaseHeight + lift;
		var outline = roof.Outline();
		var gesture = ArchGesture.On( ArchKind.Roof, roof.Id );
		var free = FreeDeck( roof, building );

		var changed = free && ArchShapeHandles.Corners( tool, gesture, outline, height );

		changed |= free && ArchShapeHandles.Edges( tool, gesture, outline, height );

		if ( changed ) {
			var from = roof.Outline();

			ArchFootprint.Bounds( from, out var fromMin, out var fromMax );
			ArchFootprint.Bounds( outline, out var toMin, out var toMax );

			var map = ArchRemap.Between( fromMin, fromMax, toMin, toMax );

			roof.Reshape( outline );

			foreach ( var light in roof.Lights ) {
				var lightMin = light.Min;
				var lightMax = light.Max;

				map.Box( ref lightMin, ref lightMax );

				light.Min = lightMin;
				light.Max = lightMax;
			}
		}

		if ( ArchShapeHandles.Height( tool, gesture.At( "eave" ), Middle( outline ), roof.BaseHeight, out var eave, Gizmo.Colors.Roll ) ) {
			roof.BaseHeight = eave;
			changed = true;
		}

		changed |= Rake( tool, roof, gesture, lift );

		return changed;
	}

	static bool Rake( ArchTool tool, ArchRoofPart roof, ArchGesture gesture, float lift ) {
		foreach ( var grip in ArchRoofRake.Grips( roof ) ) {
			if ( !ArchShapeHandles.Lift( tool, gesture.At( "rake", grip.Index ), grip.At, grip.Height + lift, out var raked, Gizmo.Colors.Pitch ) ) {
				continue;
			}

			ArchRoofRake.Apply( roof, grip.At, grip.Outward, raked - lift );

			return true;
		}

		return false;
	}

	static bool FreeDeck( ArchRoofPart roof, ArchBuilding building ) {
		if ( building is null ) {
			return true;
		}

		ArchFootprint.Bounds( roof.Outline(), out var min, out var max );

		foreach ( var room in building.Rooms.Where( room => room.Floor == roof.Level ) ) {
			var loop = ArchFloorGen.Footprint( room );

			if ( loop.Count < 3 ) {
				continue;
			}

			ArchFootprint.Bounds( loop, out var low, out var high );

			if ( low.x <= max.x && high.x >= min.x && low.y <= max.y && high.y >= min.y ) {
				return false;
			}
		}

		return true;
	}

	static bool Placed( ArchTool tool, ArchGesture gesture, Vector2 min, Vector2 max, float height, List<Vector2> outline, out Vector2 shifted, out float lifted, out float turned, float step = 0f ) {
		var centre = (min + max) * 0.5f;
		var at = new Vector3( centre.x, centre.y, height );

		shifted = Vector2.Zero;
		lifted = 0f;
		turned = 0f;

		if ( ArchShapeHandles.Position( tool, gesture.At( "move" ), at, out var moved ) ) {
			shifted = new Vector2( moved.x - centre.x, moved.y - centre.y );
			lifted = moved.z - height;

			return true;
		}

		if ( ArchShapeHandles.Turned( gesture.At( "turn" ), at, out var spun, step ) ) {
			turned = spun;

			var swung = ArchFootprint.Turned( outline, centre, spun );

			outline.Clear();
			outline.AddRange( swung );

			return true;
		}

		return false;
	}

	static bool Platform( ArchTool tool, ArchPlatformPart platform ) {
		var outline = platform.Outline();
		var gesture = ArchGesture.On( ArchKind.Platform, platform.Id );

		if ( Placed( tool, gesture, platform.Min, platform.Max, platform.TopHeight, outline, out var shifted, out var lifted, out var swung ) ) {
			var centre = (platform.Min + platform.Max) * 0.5f;

			platform.Reshape( outline.Select( point => point + shifted ).ToList() );
			platform.GradeHeight += lifted;
			platform.TopHeight += lifted;

			if ( platform.Ramp ) {
				platform.RampYaw = ArchShapeHandles.Facing( platform.RampYaw + swung );
			}

			foreach ( var core in platform.Stairs.Select( stair => stair.Core ).Where( core => core is not null ) ) {
				core.Origin = ArchFootprint.Turned( new[] { core.Origin }, centre, swung )[0] + shifted;
				core.Yaw += swung;
			}

			return true;
		}

		// Regular shapes get box handles; irregular ones keep per-corner widgets.
		var boxed = ArchOutlineForm.Of( outline ) == ArchBoolForm.Circle || ArchFootprint.IsRectilinear( outline );

		if ( ArchShapeHandles.Volume( tool, gesture.At( "volume" ), platform.Min, platform.Max, platform.GradeHeight, platform.TopHeight, out var pulled ) ) {
			return Pull( platform, outline, pulled );
		}

		if ( platform.Ramp
			&& ArchShapeHandles.Height( tool, gesture.At( "foot" ), Toe( platform, outline ), ArchRamp.Foot( platform ), out var toe, Gizmo.Colors.Pitch ) ) {
			platform.RampFall = Math.Clamp( platform.TopHeight - toe, ArchGridService.FinestSize, platform.Rise );

			return true;
		}

		if ( boxed ) {
			return false;
		}

		var changed = ArchShapeHandles.Corners( tool, gesture, outline, platform.TopHeight );

		changed |= ArchShapeHandles.Edges( tool, gesture, outline, platform.TopHeight );

		if ( changed ) {
			var from = platform.Outline();

			ArchFootprint.Bounds( from, out var fromMin, out var fromMax );
			ArchFootprint.Bounds( outline, out var toMin, out var toMax );

			var map = ArchRemap.Between( fromMin, fromMax, toMin, toMax );

			platform.Reshape( outline );

			foreach ( var stair in platform.Stairs ) {
				ArchCarry.Stair( stair, map );
			}
		}

		var middle = Middle( outline );

		if ( ArchShapeHandles.Height( tool, gesture.At( "top" ), middle, platform.TopHeight, out var top, Gizmo.Colors.Roll ) ) {
			platform.TopHeight = MathF.Max( platform.GradeHeight + 1f, top );
			changed = true;
		}

		if ( ArchShapeHandles.Height( tool, gesture.At( "grade" ), middle, platform.GradeHeight, out var grade, Gizmo.Colors.Pitch ) ) {
			platform.GradeHeight = MathF.Min( platform.TopHeight - 1f, grade );
			changed = true;
		}

		return changed;
	}

	static Vector2 Toe( ArchPlatformPart platform, IReadOnlyList<Vector2> outline ) {
		var climb = ArchRamp.Climb( platform.RampYaw );

		return Middle( outline ) - climb * (ArchRamp.Run( outline, climb ) * 0.5f);
	}

	static bool Beam( ArchTool tool, ArchBeamPart beam ) {
		var outline = beam.Outline();
		var gesture = ArchGesture.On( ArchKind.Beam, beam.Id );

		if ( Placed( tool, gesture, beam.Min, beam.Max, beam.TopHeight, outline, out var shifted, out var lifted, out var turned ) ) {
			beam.Reshape( outline.Select( point => point + shifted ).ToList() );
			beam.TopHeight += lifted;
			beam.Yaw = ArchShapeHandles.Facing( beam.Yaw + turned );

			return true;
		}

		if ( !ArchShapeHandles.Volume( tool, gesture.At( "volume" ), beam.Min, beam.Max, beam.Soffit, beam.TopHeight, out var pulled ) ) {
			return false;
		}

		var toMin = new Vector2( pulled.Mins.x, pulled.Mins.y );
		var toMax = new Vector2( pulled.Maxs.x, pulled.Maxs.y );

		ArchRemap.Between( beam.Min, beam.Max, toMin, toMax ).Loop( outline );

		beam.Reshape( outline );
		beam.TopHeight = pulled.Maxs.z;
		beam.Drop = MathF.Max( 1f, pulled.Maxs.z - pulled.Mins.z );

		return true;
	}

	// Remaps the loop into new bounds rather than rebuilding, preserving circles and segment counts.
	static bool Pull( ArchPlatformPart platform, List<Vector2> outline, BBox pulled ) {
		var toMin = new Vector2( pulled.Mins.x, pulled.Mins.y );
		var toMax = new Vector2( pulled.Maxs.x, pulled.Maxs.y );
		var map = ArchRemap.Between( platform.Min, platform.Max, toMin, toMax );

		map.Loop( outline );

		platform.Reshape( outline );
		platform.GradeHeight = pulled.Mins.z;
		platform.TopHeight = MathF.Max( pulled.Mins.z + 1f, pulled.Maxs.z );

		foreach ( var stair in platform.Stairs ) {
			ArchCarry.Stair( stair, map );
		}

		return true;
	}

	static bool Cut( ArchTool tool, ArchCutPart cut ) {
		var building = tool.Plan.OwnerOf( cut );
		var lift = building is null ? 0f : ArchAsks.Lift( tool.Plan, building, tool.Kit );

		var gesture = ArchGesture.On( ArchKind.Cut, cut.Id );

		if ( cut.Segments.Count == 1 && cut.Segments[0] is { HasLoop: true } only ) {
			var band = ArchCut.WorldBand( only, lift );

			ArchFootprint.Bounds( only.Loop, out var low, out var high );

			var step = cut.SnapAngle ? ArchGridService.AngleStep : 0f;

			if ( Placed( tool, gesture, low, high, band.y, only.Loop.ToList(), out var shifted, out var lifted, out var swung, step ) ) {
				ArchCut.Shift( only, shifted );
				ArchCut.Turn( only, (low + high) * 0.5f, swung, cut.SnapAngle );

				only.BaseHeight += lifted;
				only.TopHeight += lifted;

				return true;
			}

			return Volume( tool, gesture, only, lift );
		}

		var changed = false;

		for ( var index = 0; index < cut.Segments.Count; index++ ) {
			var segment = cut.Segments[index];
			var leg = gesture.At( "leg", index );
			var band = ArchCut.WorldBand( segment, lift );

			if ( index == 0
				&& ArchShapeHandles.Landed( tool, leg.At( "start" ), new Vector3( segment.Start.x, segment.Start.y, band.y ), out var start ) ) {
				changed |= ArchCut.Reshape( segment, start, segment.End, cut.SnapAngle );
			}

			if ( ArchShapeHandles.Landed( tool, leg.At( "end" ), new Vector3( segment.End.x, segment.End.y, band.y ), out var end ) ) {
				changed |= ArchCut.Reshape( segment, segment.Start, end, cut.SnapAngle );
			}

			var middle = segment.HasLoop ? Middle( segment.Loop ) : (segment.Start + segment.End) * 0.5f;

			if ( segment.HasLoop ) {
				changed |= ArchShapeHandles.Corners( tool, leg, segment.Loop, band.y );
			} else {
				var across = segment.Axes.Across;
				var edge = middle + across * (MathF.Max( 1f, segment.Width ) * 0.5f);

				if ( ArchShapeHandles.Width( tool, leg.At( "width" ), new Vector3( edge.x, edge.y, band.y ), middle, across, 12f, out var widened, Gizmo.Colors.Green ) ) {
					segment.Width = widened;
					changed = true;
				}
			}

			if ( ArchShapeHandles.Height( tool, leg.At( "head" ), middle, band.y, out var head, Gizmo.Colors.Roll ) ) {
				segment.TopHeight = MathF.Max( segment.BaseHeight + 1f, head - lift );
				changed = true;
			}

			if ( ArchShapeHandles.Height( tool, leg.At( "foot" ), middle, band.x, out var foot, Gizmo.Colors.Pitch ) ) {
				segment.BaseHeight = MathF.Min( segment.TopHeight - 1f, foot - lift );
				changed = true;
			}
		}

		if ( changed ) {
			ArchCut.Relink( cut );
		}

		return changed;
	}

	static bool Volume( ArchTool tool, ArchGesture gesture, ArchCutSegment leg, float lift ) {
		ArchFootprint.Bounds( leg.Loop, out var min, out var max );
		var band = ArchCut.WorldBand( leg, lift );

		if ( !ArchShapeHandles.Volume( tool, gesture, min, max, band.x, band.y, out var pulled ) ) {
			return false;
		}

		var toMin = new Vector2( pulled.Mins.x, pulled.Mins.y );
		var toMax = new Vector2( pulled.Maxs.x, pulled.Maxs.y );

		var yaw = leg.Yaw;

		ArchRemap.Between( min, max, toMin, toMax ).Loop( leg.Loop );

		// Refit at existing yaw — deriving yaw from the new bounds would swing a turned cut.
		ArchCut.Fit( leg, yaw );

		leg.BaseHeight = pulled.Mins.z - lift;
		leg.TopHeight = MathF.Max( leg.BaseHeight + 1f, pulled.Maxs.z - lift );

		return true;
	}

	static bool Blueprint( ArchTool tool, ArchBuilding building ) {
		if ( !Bounds( building, out var min, out var max ) ) {
			return false;
		}

		var gesture = ArchGesture.On( ArchKind.Building, building.Id );
		var height = tool.LevelHeight;

		if ( Shell( building ) is { Count: >= 3 } shell ) {
			if ( ArchShapeHandles.Corners( tool, gesture, shell, height, Carried( building ) )
				| ArchShapeHandles.Edges( tool, gesture, shell, height, CarriedAlong( building ) ) ) {
				return true;
			}
		} else if ( Boxed( tool, gesture, building, min, max, height ) ) {
			return true;
		}

		var centre = (min + max) * 0.5f;

		// Pivot must be captured before the ring reports movement.
		var pivoted = Pivot( building, out var about );

		if ( ArchShapeHandles.Turned( gesture.At( "turn" ), new Vector3( centre.x, centre.y, height ), out var swung, ArchGridService.AngleStep ) ) {
			return pivoted && ArchCarry.Turn( building, about, swung, tool.Plan );
		}

		if ( !ArchShapeHandles.Move( tool, gesture.At( "body" ), centre, out var moved ) || (moved - centre).Length < 0.01f ) {
			return false;
		}

		ArchCarry.Shift( building, moved - centre, tool.Plan );

		return true;
	}

	// Bounds rescale: the new box is measured against the opposite corner, not re-derived bounds.
	static bool Boxed( ArchTool tool, ArchGesture gesture, ArchBuilding building, Vector2 min, Vector2 max, float height ) {
		var boxed = ArchFootprint.Rect( min, max );
		var corners = ArchShapeHandles.Corners( tool, gesture, boxed, height );
		var edges = ArchShapeHandles.Edges( tool, gesture, boxed, height );

		if ( !corners && !edges ) {
			return false;
		}

		var toMin = min;
		var toMax = max;

		if ( corners ) {
			var rect = ArchFootprint.Rect( min, max );
			var index = Enumerable.Range( 0, rect.Count ).First( at => (boxed[at] - rect[at]).Length > 0.001f );
			var opposite = rect[(index + 2) % rect.Count];

			toMin = Vector2.Min( boxed[index], opposite );
			toMax = Vector2.Max( boxed[index], opposite );
		} else {
			ArchFootprint.Bounds( boxed, out toMin, out toMax );
		}

		if ( toMax.x - toMin.x < 1f || toMax.y - toMin.y < 1f ) {
			return true;
		}

		ArchCarry.Unit( building, ArchRemap.Between( min, max, toMin, toMax ), tool.Plan );

		return true;
	}

	// Shell is held for the whole gesture — re-tracing the union mid-drag would rebind corner indices.
	static readonly Dictionary<int, List<Vector2>> heldShells = new();

	public static List<Vector2> Shell( ArchBuilding building ) {
		// Only held during scale gestures — move/turn need a live re-trace.
		var binds = ArchShapeHandles.Mode == ArchHandleMode.Scale;
		var holding = binds && Gizmo.IsLeftMouseDown;

		if ( !holding ) {
			heldShells.Clear();
		} else if ( heldShells.TryGetValue( building.Id, out var held ) ) {
			return held;
		}

		var outers = ArchFootprint.Outer( ArchFootprint.Union( building.Rooms
			.Select( ArchFloorGen.Footprint )
			.Where( loop => loop.Count >= 3 )
			.ToList() ) ).ToList();

		var shell = outers.Count == 1 ? outers[0] : null;

		if ( holding && shell is not null ) {
			heldShells[building.Id] = shell;
		}

		return shell;
	}

	static Func<Vector2, Vector2, bool> Carried( ArchBuilding building ) {
		return ( from, to ) => ArchCarry.Corner( building, from, to );
	}

	static Func<Vector2, Vector2, Vector2, bool> CarriedAlong( ArchBuilding building ) {
		return ( from, to, reach ) => ArchCarry.Along( building, from, to, reach );
	}

	static bool Assembly( ArchTool tool, ArchSiteAssembly group ) {
		var members = Members( tool.Plan, group ).ToList();

		if ( members.Count == 0 || !Bounds( tool.Plan, members, out var min, out var max ) ) {
			return false;
		}

		var centre = (min + max) * 0.5f;

		if ( !ArchShapeHandles.Move( tool, ArchGesture.On( ArchKind.Assembly, group.Id ), centre, out var moved ) || (moved - centre).Length < 0.01f ) {
			return false;
		}

		var map = ArchRemap.Translation( moved - centre );

		foreach ( var member in members ) {
			switch ( member ) {
				case ArchBuilding building:
					ArchCarry.Unit( building, map, tool.Plan );
					break;

				case ArchRoom room:
					ArchCarry.Room( room, map );
					break;

				case ArchRoofPart roof:
					ArchCarry.Roof( roof, map );
					break;

				case ArchRoadPart road:
					map.Nodes( road.Nodes );
					break;
			}
		}

		return true;
	}

	static IEnumerable<object> Members( ArchPlan plan, ArchSiteAssembly group ) {
		var owned = plan.Buildings.Where( building => group.Children.Contains( building.Id ) ).ToList();

		foreach ( var building in owned ) {
			yield return building;
		}

		foreach ( var room in plan.AllRooms().Where( room => group.Children.Contains( room.Id ) ) ) {
			if ( !owned.Contains( plan.OwnerOf( room ) ) ) {
				yield return room;
			}
		}

		foreach ( var roof in plan.Buildings.SelectMany( building => building.Roofs ).Where( roof => group.Children.Contains( roof.Id ) ) ) {
			if ( !owned.Contains( plan.Buildings.FirstOrDefault( building => building.Roofs.Contains( roof ) ) ) ) {
				yield return roof;
			}
		}

		foreach ( var road in plan.Roads().Where( road => group.Children.Contains( road.Id ) ) ) {
			yield return road;
		}
	}

	static bool Bounds( ArchPlan plan, IEnumerable<object> members, out Vector2 min, out Vector2 max ) {
		var low = new Vector2( float.MaxValue, float.MaxValue );
		var high = new Vector2( float.MinValue, float.MinValue );
		var found = false;

		foreach ( var member in members ) {
			var loops = member switch {
				ArchBuilding building => building.Rooms.Select( ArchFloorGen.Footprint ),
				ArchRoom room => new[] { ArchFloorGen.Footprint( room ) }.AsEnumerable(),
				ArchRoofPart roof => new[] { roof.Outline() }.AsEnumerable(),
				ArchRoadPart road => new[] { road.Nodes.Select( node => new Vector2( node.Position.x, node.Position.y ) ).ToList() }.AsEnumerable(),
				_ => Enumerable.Empty<List<Vector2>>()
			};

			foreach ( var loop in loops.Where( loop => loop.Count >= 2 ) ) {
				ArchFootprint.Bounds( loop, out var loopMin, out var loopMax );

				low = Vector2.Min( low, loopMin );
				high = Vector2.Max( high, loopMax );
				found = true;
			}
		}

		min = low;
		max = high;

		return found;
	}

	static bool Room( ArchTool tool, ArchRoom room ) {
		var outline = ArchFloorGen.Footprint( room );

		if ( outline.Count < 3 ) {
			return false;
		}

		var building = tool.Plan.OwnerOf( room );
		var gesture = ArchGesture.On( ArchKind.Room, room.Id );
		var height = room.BaseHeight;

		if ( ArchShapeHandles.Corners( tool, gesture, outline, height, Carried( building ) )
			| ArchShapeHandles.Edges( tool, gesture, outline, height, CarriedAlong( building ) ) ) {
			return true;
		}

		var centre = Middle( outline );

		if ( !ArchShapeHandles.Move( tool, gesture.At( "body" ), centre, out var moved ) || (moved - centre).Length < 0.01f ) {
			return false;
		}

		ArchCarry.Shift( room, moved - centre );

		return true;
	}

	static bool Wall( ArchTool tool, ArchWall wall, ArchRoom room ) {
		var building = room is null ? null : tool.Plan.OwnerOf( room );
		var floor = room?.BaseHeight ?? tool.LevelHeight;
		var gesture = ArchGesture.On( ArchKind.Wall, wall.Id );
		var middle = (wall.Start + wall.End) * 0.5f;

		if ( Slid( tool, gesture, building, wall, floor, middle ) ) {
			return true;
		}

		var changed = End( tool, gesture.At( "start" ), building, floor, wall.Start, corner => wall.Start = corner );

		changed |= End( tool, gesture.At( "end" ), building, floor, wall.End, corner => wall.End = corner );

		var standing = ArchWallSection.Height( wall, room, tool.Kit );

		if ( ArchShapeHandles.Height( tool, gesture.At( "head" ), middle, floor + standing, out var head, Gizmo.Colors.Roll ) ) {
			wall.Height = MathF.Max( 1f, head - floor );
			changed = true;
		}

		return changed;
	}

	// Both ends shift by the same delta to preserve wall length and angle.
	static bool Slid( ArchTool tool, ArchGesture gesture, ArchBuilding building, ArchWall wall, float floor, Vector2 middle ) {
		if ( !ArchShapeHandles.Position( tool, gesture.At( "body" ), new Vector3( middle.x, middle.y, floor ), out var moved ) ) {
			return false;
		}

		var shift = new Vector2( moved.x, moved.y ) - middle;

		if ( shift.Length < 0.01f ) {
			return false;
		}

		var from = wall.Start;
		var to = wall.End;

		wall.Start = from + shift;
		wall.End = to + shift;

		ArchCarry.Corner( building, from, wall.Start );
		ArchCarry.Corner( building, to, wall.End );

		return true;
	}

	// Landed (delta-snapped), not absolute-snapped — off-grid corners would jump on grab.
	static bool End( ArchTool tool, ArchGesture gesture, ArchBuilding building, float floor, Vector2 corner, Action<Vector2> place ) {
		if ( !ArchShapeHandles.Landed( tool, gesture, new Vector3( corner.x, corner.y, floor ), out var moved ) ) {
			return false;
		}

		place( moved );
		ArchCarry.Corner( building, corner, moved );

		return true;
	}

	static bool Opening( ArchTool tool, ArchOpening opening, ArchRoom room ) {
		if ( room?.Walls.FirstOrDefault( candidate => candidate.Openings.Contains( opening ) ) is not { } wall ) {
			return false;
		}

		var floor = room.BaseHeight;
		var gesture = ArchGesture.On( ArchKind.Opening, opening.Id );
		var changed = false;

		foreach ( var edge in new[] { false, true } ) {
			var along = edge ? opening.Right : opening.Left;
			var at = wall.PointAt( Math.Clamp( along, 0f, wall.Length ) );

			if ( !ArchShapeHandles.Reach( tool, gesture.At( edge ? "right" : "left" ),
				new Vector3( at.x, at.y, floor + opening.SillHeight ), wall.Start, wall.Direction, out var pulled ) ) {
				continue;
			}

			var other = edge ? opening.Left : opening.Right;

			opening.Width = MathF.Max( 4f, MathF.Abs( pulled - other ) );
			opening.Offset = (pulled + other) * 0.5f;
			changed = true;
		}

		var centre = wall.PointAt( Math.Clamp( opening.Offset, 0f, wall.Length ) );

		if ( ArchShapeHandles.Height( tool, gesture.At( "head" ), centre, floor + opening.Top, out var head, Gizmo.Colors.Roll ) ) {
			opening.Height = MathF.Max( 4f, head - floor - opening.SillHeight );
			changed = true;
		}

		if ( ArchShapeHandles.Height( tool, gesture.At( "sill" ), centre, floor + opening.SillHeight, out var sill, Gizmo.Colors.Pitch ) ) {
			var top = opening.Top;

			opening.SillHeight = MathF.Max( 0f, MathF.Min( sill - floor, top - 4f ) );
			opening.Height = MathF.Max( 4f, top - opening.SillHeight );
			changed = true;
		}

		return changed;
	}

	static bool Modifier( ArchTool tool, ArchWallModPart modifier, ArchRoom room ) {
		if ( room?.Walls.FirstOrDefault( candidate => candidate.Modifiers.Contains( modifier ) ) is not { } wall ) {
			return false;
		}

		var floor = room.BaseHeight;
		var standing = ArchWallSection.Height( wall, room, tool.Kit );
		var top = modifier.Height > 0.5f ? modifier.Base + modifier.Height : standing;
		var gesture = ArchGesture.On( ArchKind.WallMod, modifier.Id );
		var changed = false;

		foreach ( var edge in new[] { false, true } ) {
			var along = edge ? modifier.Right : modifier.Left;
			var at = wall.PointAt( Math.Clamp( along, 0f, wall.Length ) );

			if ( !ArchShapeHandles.Reach( tool, gesture.At( edge ? "right" : "left" ),
				new Vector3( at.x, at.y, floor + modifier.Base ), wall.Start, wall.Direction, out var pulled ) ) {
				continue;
			}

			var other = edge ? modifier.Left : modifier.Right;

			modifier.Width = MathF.Max( 2f, MathF.Abs( pulled - other ) );
			modifier.Along = (pulled + other) * 0.5f;
			changed = true;
		}

		var centre = wall.PointAt( Math.Clamp( modifier.Along, 0f, wall.Length ) );

		if ( ArchShapeHandles.Height( tool, gesture.At( "head" ), centre, floor + top, out var head, Gizmo.Colors.Roll ) ) {
			var reach = head - floor - modifier.Base;

			// Height 0 means "climb to wall head" — landing near the head resets to that sentinel.
			var atHead = MathF.Abs( reach - standing ) < ArchGridService.FinestSize;

			modifier.Height = atHead ? 0f : MathF.Max( 2f, reach );
			changed = true;
		}

		if ( ArchShapeHandles.Height( tool, gesture.At( "foot" ), centre, floor + modifier.Base, out var foot, Gizmo.Colors.Pitch ) ) {
			modifier.Base = Math.Clamp( foot - floor, 0f, top - 2f );
			modifier.Height = MathF.Max( 2f, top - modifier.Base );
			changed = true;
		}

		return changed;
	}

	// Following trims only offer a lift handle; hand-drawn trims get per-node handles.
	static bool Trim( ArchTool tool, ArchTrimPart trim ) {
		if ( trim.Path.Count == 0 ) {
			return false;
		}

		var gesture = ArchGesture.On( ArchKind.Trim, trim.Id );

		if ( !trim.Follows ) {
			return Points( tool, gesture, trim.Path );
		}

		var centre = trim.Path.Aggregate( Vector3.Zero, ( sum, point ) => sum + point ) / trim.Path.Count;

		if ( !ArchShapeHandles.Height( tool, gesture.At( "lift" ), centre, centre.z, out var lifted, Gizmo.Colors.Roll ) ) {
			return false;
		}

		trim.Lift += lifted - centre.z;
		ArchTrimAffector.Refollow( tool.Plan, trim );

		return true;
	}

	static bool Points( ArchTool tool, ArchGesture gesture, List<Vector3> path ) {
		var changed = false;

		for ( var index = 0; index < path.Count; index++ ) {
			if ( !ArchShapeHandles.Control( tool, gesture.At( "point", index ), path[index], out var moved ) ) {
				continue;
			}

			path[index] = moved;
			changed = true;
		}

		return changed;
	}

	static bool Downpipe( ArchTool tool, ArchDownpipePart pipe ) {
		var gesture = ArchGesture.On( ArchKind.Downpipe, pipe.Id );
		var changed = false;

		if ( ArchShapeHandles.Move( tool, gesture, pipe.Outlet, out var moved ) ) {
			pipe.Outlet = moved;
			changed = true;
		}

		if ( ArchShapeHandles.Height( tool, gesture.At( "top" ), pipe.Outlet, pipe.TopHeight, out var top, Gizmo.Colors.Roll ) ) {
			pipe.TopHeight = MathF.Max( pipe.BaseHeight + 1f, top );
			changed = true;
		}

		if ( ArchShapeHandles.Height( tool, gesture.At( "shoe" ), pipe.Wall, pipe.BaseHeight, out var foot, Gizmo.Colors.Pitch ) ) {
			pipe.BaseHeight = MathF.Min( pipe.TopHeight - 1f, foot );
			changed = true;
		}

		return changed;
	}

	static bool Ladder( ArchTool tool, ArchLadderPart ladder ) {
		var gesture = ArchGesture.On( ArchKind.Ladder, ladder.Id );
		var changed = false;

		if ( ArchShapeHandles.Move( tool, gesture, ladder.Anchor, out var moved ) ) {
			ladder.Anchor = moved;
			changed = true;
		}

		if ( ArchShapeHandles.Height( tool, gesture.At( "foot" ), ladder.Anchor, ladder.BaseHeight, out var foot, Gizmo.Colors.Pitch ) ) {
			ladder.BaseHeight = MathF.Min( ladder.TopHeight - 1f, foot );
			changed = true;
		}

		return changed;
	}

	static bool Balcony( ArchTool tool, ArchBalconyPart balcony ) {
		var gesture = ArchGesture.On( ArchKind.Balcony, balcony.Id );
		var facing = balcony.Facing;
		var along = balcony.Along;
		var half = MathF.Max( 6f, balcony.Width * 0.5f );
		var rail = balcony.RailHeight > 1f ? balcony.RailHeight : tool.Kit.BalconyRail;
		var depth = balcony.IsJuliet ? ArchLap.Proud( tool.Kit ) : balcony.Reach;

		if ( ArchShapeHandles.Move( tool, gesture, balcony.Anchor, out var moved ) ) {
			balcony.Anchor = moved;

			return true;
		}

		ArchFootprint.Bounds( new List<Vector2>
		{
			balcony.Anchor - along * half,
			balcony.Anchor + along * half,
			balcony.Anchor - along * half + facing * depth,
			balcony.Anchor + along * half + facing * depth
		}, out var min, out var max );

		if ( !ArchShapeHandles.Volume( tool, gesture.At( "volume" ), min, max, balcony.BaseHeight, balcony.BaseHeight + rail, out var pulled ) ) {
			return false;
		}

		var span = new Vector2( pulled.Maxs.x - pulled.Mins.x, pulled.Maxs.y - pulled.Mins.y );

		balcony.Width = ArchGridService.Least( Extent( span, along ), 12f );
		balcony.BaseHeight = pulled.Mins.z;
		balcony.RailHeight = MathF.Max( 12f, pulled.Maxs.z - pulled.Mins.z );

		if ( !balcony.IsJuliet ) {
			balcony.Reach = MathF.Sign( balcony.Reach ) * ArchGridService.Least( Extent( span, facing ), 4f );
		}

		return true;
	}

	static float Extent( Vector2 span, Vector2 axis ) => MathF.Abs( axis.x ) * span.x + MathF.Abs( axis.y ) * span.y;

	static bool Approach( ArchTool tool, ArchApproachPart approach ) {
		var gesture = ArchGesture.On( ArchKind.Approach, approach.Id );
		var outward = ArchApproachAxes.Outward( approach );
		var across = ArchApproachAxes.Across( approach );
		var run = MathF.Max( 12f, approach.Run );

		if ( ArchShapeHandles.Move( tool, gesture, approach.Origin, out var moved ) ) {
			approach.Origin = moved;

			return true;
		}

		if ( ArchShapeHandles.Reach( tool, gesture.At( "run" ),
			Standing( tool, approach.Origin + outward * run ),
			approach.Origin, outward, out var reached ) ) {
			approach.Run = MathF.Max( 12f, reached );

			return true;
		}

		var middle = approach.Origin + outward * run * 0.5f;

		if ( !ArchShapeHandles.Width( tool, gesture.At( "width" ),
			Standing( tool, middle + across * ArchApproachAxes.HalfWidth( approach ) ),
			middle, across, 12f, out var width ) ) {
			return false;
		}

		approach.Width = width;

		return true;
	}

	static Vector3 Standing( ArchTool tool, Vector2 at ) => new( at.x, at.y, tool.LevelHeight );

	static Vector2 Middle( IReadOnlyList<Vector2> outline ) {
		if ( outline is not { Count: > 0 } ) {
			return Vector2.Zero;
		}

		ArchFootprint.Bounds( outline, out var min, out var max );

		return (min + max) * 0.5f;
	}
}