Editor/Handles/ArchHandles.cs

Editor UI/toolging code for architectural editing handles. It routes picked items to specific handlers and implements gizmo logic for moving, scaling and reshaping buildings, rooms, roofs, platforms, cuts, walls, openings, trims, roads and other architectural parts.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// One rule decides what a part offers: footprint→corners/edges, spline→nodes, position→move, height only in an elevation.
public static class ArchHandles
{
	public static bool Draw( ArchTool tool, ArchSelection picked )
	{
		ArchShapeHandles.BeginFrame();

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

		// A locked layer still selects and still shows its properties; it just refuses to be dragged.
		if ( tool.LayerTree.IsLocked( picked.Item ) )
		{
			return false;
		}

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

		// The eye hides the layer, so it hides the widget with it - a gizmo floating over geometry that is
		// not being drawn is the one thing you cannot be looking at.
		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
		};
	}

	// Room footprints only - the empty placeholder EnsureTarget seeds has none, so it offers no move.
	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;
	}

	// Reshape, not Footprint: Min/Max must follow the loop or the ridge lands off the deck.
	// The outline widgets stand only on a FREE deck (a canopy, a walkway roof): over rooms the deck is
	// CLIPPED to them, so dragging the outline out builds nothing — the shell's own widgets move the
	// rooms, and ArchCarry brings the roof outline with them.
	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 )
		{
			// The lights ride the deck they are cut into, or reshaping a roof leaves them off it.
			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;
		}

		return changed;
	}

	// Free means no room on the deck's own level stands under it — the same rooms the region service
	// clips against, tested by bounds because a per-frame footprint union is a resolve in the draw path.
	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;
	}

	// Scale is the box, travel is the engine's mover and turn is its ring - three separate gestures on one
	// shape, because a solid you can only stretch is one you cannot put anywhere.
	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;

			// Turning the shape turns the climb with it, or a ramp swung round keeps falling the way the yard
			// used to run and its head lands at the wrong end.
			if ( platform.Ramp )
			{
				platform.RampYaw = ArchShapeHandles.Facing( platform.RampYaw + swung );
			}

			// The flights carved into the deck ride it, or the cut lands off the platform it cut.
			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;
		}

		// A solid dragged as one shape is a box: the six faces carry its footprint AND its rise together,
		// so its height is pulled where you are looking at it rather than off a pin in the middle. Its
		// corners stay draggable only when the footprint is an irregular one no box could describe - a
		// circle's are derived from its bounds, and eight of them would only be a thicket.
		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 );
		}

		// The one height a box cannot carry: a box has two faces where a wedge has three, so the foot is pinned
		// on the elevation and everything else still comes off the volume.
		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 )
		{
			// The flights carved into the deck ride it, or the cut lands off the platform it cut.
			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;
	}

	// The low end of the climb, so the pin stands where the author is looking at the foot rather than out in
	// the middle of a run they are trying to judge.
	static Vector2 Toe( ArchPlatformPart platform, IReadOnlyList<Vector2> outline )
	{
		var climb = ArchRamp.Climb( platform.RampYaw );

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

	// A beam IS a box, so it gets the box: pulling its bottom face down is how a beam gets deeper, and the
	// drop follows from the band rather than being typed beside it.
	static bool Beam( ArchTool tool, ArchBeamPart beam )
	{
		var outline = beam.Outline();
		var gesture = ArchGesture.On( ArchKind.Beam, beam.Id );

		// Turning the zone turns the members with it, or the slats keep running the way the room used to.
		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;
	}

	// The loop is REMAPPED into the new bounds rather than rebuilt, so a circle keeps the roundness and the
	// segment count it was authored with - and the flights carved into the deck ride it, or a cut lands off
	// the platform it cut.
	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 );

		// A cut drawn as one box or circle IS a box, so it gets the box dragger and nothing else. A chain
		// of legs is not - it winds round a tower - so those keep the per-leg widgets below.
		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 );

			// Read on a COPY: the shape itself travels and swings through ArchCut, which carries the loop and
			// the run together. Letting the gesture swing the loop in place is what left the run behind.
			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 );

			// Only the first leg has a start of its own; the rest follow through Relink.
			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 )
			{
				// A free footprint is reshaped corner by corner - there is no run to pull.
				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;
	}

	// The whole shape pulled by its faces. The loop is REMAPPED into the new bounds rather than rebuilt,
	// so a circle stays the circle it was authored as and keeps its segment count - rebuilding it here
	// would be a second derivation of a shape the drag already resolved.
	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 );

		// Refitted along the yaw it already stood at: taking the run from the new BOUNDS made it the box's
		// diagonal, so pulling a turned cut's face swung the frame the carve reads by tens of degrees.
		ArchCut.Fit( leg, yaw );

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

		return true;
	}

	// The blueprint: the widget stands on the shell's OWN outline - the union of its rooms - and every corner of
	// it is carried, so an L, a U or a splayed wing is edited as the shape it is. A bounds rescale is kept only
	// for a shell that makes no single outline (a detached wing), because there is no one loop to hand a widget
	// then and scaling the lot proportionally is the only honest answer.
	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;

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

		ArchCarry.Shift( building, moved - centre );

		return true;
	}

	// The rescale path. A dragged corner names the new box against the corner OPPOSITE it - re-deriving bounds
	// from the mutated rect reads the old extremes off the three corners that never moved, which made every
	// inward drag a no-op.
	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 ) );

		return true;
	}

	// The ring a gesture is holding is kept for the WHOLE gesture: the union is re-traced from a different
	// vertex the moment a corner moves, so a re-derived ring rebinds every index mid-drag and the held corner
	// teleports by another corner's travel.
	static readonly Dictionary<int, List<Vector2>> heldShells = new();

	// The shell as ONE outer ring, holes and all, where the rooms make one. Two outers is a detached wing, and
	// no single ring of corners describes it - but a courtyard is one shell with a hole, not two shells.
	public static List<Vector2> Shell( ArchBuilding building )
	{
		if ( !Gizmo.IsLeftMouseDown )
		{
			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 ( Gizmo.IsLeftMouseDown && shell is not null )
		{
			heldShells[building.Id] = shell;
		}

		return shell;
	}

	// Every corner gesture on a shell reduces to the same question, answered in exactly one place.
	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 );
	}

	// A group moves as one: that is what putting layers in the same scope buys you.
	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 );
					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;
	}

	// Only the members that own a shape of their own; a building already carries its rooms.
	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;
	}

	// A room is its walls and everything standing in it, and it is hardly ever a rectangle. So a corner is
	// CARRIED rather than the room being rescaled into new bounds: pulling the inside corner of an L changes
	// no bounds at all, so a bounds remap moved the slab and left every wall standing on that corner behind -
	// which is the shape coming apart rather than being edited.
	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;
	}

	// A wall DRAGS, as a body and by either end, and every one of those gestures is the same question:
	// an authored corner moved, so everything sharing it follows through ArchCarry. Sliding the body
	// carries both corners at once, which is what stretches the walls it meets rather than tearing off
	// them - and the reach is the whole building, so the storey above comes with the storey below.
	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 move by ONE delta, so the run keeps its length and its angle - carrying each end to its own
	// stepped coordinate would shear the wall whenever the two rounded different ways.
	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, never snapped: a corner that lands on the ladder outright jumps the instant it is grabbed, and
	// a shell whose corners are not on the grid - a turned wing, a row absorbed off a curving street - jumps
	// by however far off it stood.
	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;
	}

	// Slides ALONG its wall - off the elevation it's cut into, you'd only lose it.
	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;
	}

	// Slides along its wall like an opening, because it is the same problem: a station, a width and
	// the two heights it stands between. Dragged off the elevation it would only be lost.
	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;

			// Landing ON the wall head means "climb to the head", which is what a preset wants to keep
			// saying. ABOVE it is an explicit reach the generator honours, so a pier runs past the floor
			// line up as many storeys as it is dragged - which is why nothing asks for a count of them.
			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;
	}

	// A hand-drawn run is its nodes. A run that follows another layer's shape has no nodes to own - its
	// only authored value is how far above that shape it sits, so it gets a height widget and the path
	// re-derives under it. This is why a following run never asks how many storeys it climbs.
	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;
	}

	// A bare list of world points, one square each - a run drawn by hand has no curve nodes to offer.
	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;
	}

	// Head is not authored (steps off onto what's there); only foot and anchor get handles.
	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;
	}

	// A balcony IS a box, so it gets the box: the four flat faces carry its width and its reach, the bottom
	// carries the deck it stands at and the top the height of its guard. Whether that reach goes out as a deck
	// or in as a loggia is the MODE it was placed in, so the sign is kept rather than dragged through zero.
	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 ? ArchContact.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 = MathF.Max( 12f, ArchGridService.Fine( Extent( span, along ) ) );
		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 ) * MathF.Max( 4f, ArchGridService.Fine( Extent( span, facing ) ) );
		}

		return true;
	}

	// A world-axis span read along one of the part's own axes - exact wherever the elevation runs on the grid.
	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 );

		if ( !ArchShapeHandles.Reach( tool, gesture.At( "run" ),
			new Vector3( approach.Origin.x, approach.Origin.y, tool.LevelHeight ) + new Vector3( outward.x, outward.y, 0f ) * MathF.Max( 12f, approach.Run ),
			approach.Origin, outward, out var reached ) )
		{
			return false;
		}

		approach.Run = MathF.Max( 12f, reached );

		return true;
	}

	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;
	}
}