Editor/Tool/Subtools/ArchStairSubtool.cs

Editor subtool class for creating and editing stairs and approaches in the architecture editor. It handles walking-click creation of multi-leg stairs, concrete carving of steps, placing approaches (steps or ramps), spline driveway editing, preview/ghost drawing, input handling, and sidebar UI options.

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

namespace Sunless.Architecture;

[Title( "Stair" ), Icon( "stairs" ), Group( "10" )]
public sealed class ArchStairSubtool( ArchTool owner ) : ArchSubtool( owner )
{
	protected override ArchKind? DraftKind => ArchKind.Stair;

	enum DrivewayConnectionKind
	{
		Wall,
		Street
	}

	sealed class DrivewayConnection
	{
		public DrivewayConnectionKind Kind { get; init; }
		public Vector2 Position { get; init; }
		public Vector2 Direction { get; init; }
		public float Gap { get; init; }
		public ArchBuilding Building { get; init; }
		public ArchRoom Room { get; init; }
		public ArchWall Wall { get; init; }
		public float Along { get; init; }
		public ArchRoadPart Road { get; init; }
		public ArchFrame RoadFrame { get; init; }
	}

	public override ArchSurface[] Surfaces => new[] { ArchSurface.StairTread, ArchSurface.StairRiser, ArchSurface.StairStringer, ArchSurface.Railing };

	// A stair is the one part whose HEIGHT is half of what you are authoring, so it has to be reachable where
	// height can be seen and grabbed - the 3D view and both elevations, not the plan alone. Plan-only is what
	// left it with nothing but a box: every point handle it owns draws on the surface you are looking at.
	public override ArchViewAxis[] Works => new[] { ArchViewAxis.Free, ArchViewAxis.Top, ArchViewAxis.Front, ArchViewAxis.Side };

	// The stair just placed keeps its widgets, so a shaft can be pulled about without leaving the tool that drew it.
	protected override bool Adjusts => true;

	readonly ArchStairPart draft = new();
	readonly ArchApproachPart approachDraft = new();
	readonly List<ArchCurveNode> drivewayNodes = new();

	// The walk: one click per point the stair turns or arrives at. The clicks ARE the pattern.
	readonly List<Vector3> walk = new();

	bool splineDriveway;
	DrivewayConnection drivewayStart;
	DrivewayConnection drivewayEnd;
	bool enterDown;
	bool escapeDown;

	// Snaps to the wall face so the approach always meets the building.
	ApproachKind? approach;

	// Explicit, not inferred - the drag must land on something cuttable; a miss says so.
	bool carving;

	bool walking = true;

	// The stair the first click landed on the head of, so a walk picks a standing stair up rather than
	// starting a second one beside it - the way a pipe route clicks a node another run already owns.
	ArchStairPart continuing;

	StairFill fill;
	bool snapStoreys = true;
	int stories = 1;
	bool cutStairwell = true;
	// The drawing opens on the stair just walked. Off for anyone laying out a dozen in a row, who wants the next
	// gesture rather than a window over it.
	bool designs = true;
	bool seededRise;
	float flightRise = 128f;

	protected override bool UsesDrag => !walking && (approach != ApproachKind.Ramp || !splineDriveway);

	protected override bool TakesFreeClick => walking;

	public override void OnUpdate()
	{
		base.OnUpdate();

		var enter = global::Editor.Application.IsKeyDown( KeyCode.Enter ) || global::Editor.Application.IsKeyDown( KeyCode.Return );
		var escape = global::Editor.Application.IsKeyDown( KeyCode.Escape );

		if ( walking )
		{
			if ( enter && !enterDown )
			{
				FinishWalk();
			}

			if ( escape && !escapeDown )
			{
				CancelWalk();
			}
		}

		if ( approach == ApproachKind.Ramp && splineDriveway )
		{
			if ( enter && !enterDown )
			{
				TryFinishSplineDriveway();
			}

			if ( escape && !escapeDown )
			{
				CancelSplineDriveway();
			}
		}

		enterDown = enter;
		escapeDown = escape;
	}

	protected override string Title() => walking ? continuing is null ? "Stair" : "Continue Stair" : carving ? "Concrete Steps" : approach switch
	{
		ApproachKind.Steps => "Foundation Steps",
		ApproachKind.Ramp when splineDriveway => "Spline Driveway",
		ApproachKind.Ramp => "Driveway Ramp",
		_ => "Stair"
	};

	protected override ArchRunState Run()
	{
		if ( walking )
		{
			return new ArchRunState( walk.Count > 0, $"{walk.Count} point{(walk.Count == 1 ? "" : "s")}" );
		}

		var spline = approach == ApproachKind.Ramp && splineDriveway;

		return new ArchRunState( spline && drivewayNodes.Count > 0,
			$"{drivewayNodes.Count} point{(drivewayNodes.Count == 1 ? "" : "s")}" );
	}

	protected override void FinishRun()
	{
		if ( walking )
		{
			FinishWalk();

			return;
		}

		TryFinishSplineDriveway();
	}

	protected override void CancelRun()
	{
		if ( walking )
		{
			CancelWalk();

			return;
		}

		CancelSplineDriveway();
	}

	protected override string Advice() => walking
		? WalkAdvice()
		: carving
		? "Drag across a platform and the flight is cut OUT of its concrete. For a free-standing concrete stair, use the shaft with Solid instead."
		: approach switch
	{
		ApproachKind.Steps => "Drag out from the wall outside a door. Away from the wall is the going, along it is the width; the rise is however high the plinth stands.",
		ApproachKind.Ramp when splineDriveway => "Start on an exterior wall or street edge, click snapped control points, then click the other connection. Select the result to edit every node's width.",
		ApproachKind.Ramp => "Start on an outside wall and drag away from it. The driveway stays square to that wall and snaps in its local grid.",
		_ => "Drag the stair's shaft the way you drag a building."
	};

	void DrawApproachPreview()
	{
		if ( Draft( DragStart, DragCurrent, out var room ) is not { } draft )
		{
			return;
		}

		var building = Owner.Plan.OwnerOf( room ) ?? Owner.ActiveBuilding();
		var rise = ArchApproachGen.Rise( room, building, Owner.Kit, Owner.Plan );
		var lift = ArchAsks.Lift( Owner.Plan, building, Owner.Kit );

		var outward = ArchApproachAxes.Outward( draft );
		var across = new Vector2( -outward.y, outward.x ) * (draft.Width * 0.5f);
		var foot = draft.Origin + outward * draft.Run;

		ArchGhost.Ring( new List<Vector2>
		{
			draft.Origin + across,
			foot + across,
			foot - across,
			draft.Origin - across
		}, room.BaseHeight + lift - rise );

		ArchGhost.Note( new Vector3( foot.x, foot.y, room.BaseHeight + lift ),
			$"{draft.Kind} · rise {rise:0} · run {draft.Run:0} · {draft.Width:0} wide" );
	}

	// Snapped to the wall face - the approach must always meet the building.
	ArchApproachPart Draft( Vector2 from, Vector2 to, out ArchRoom room )
	{
		room = null;

		var wall = ArchApproachPlacement.NearestExteriorWall(
			Owner.Plan.Buildings, Owner.Level, from,
			out var building, out room, out var along, out _, out var outward );

		if ( wall is null || room is null )
		{
			return null;
		}

		var wallDirection = (wall.End - wall.Start).Normal;
		var drag = to - from;
		var grid = Owner.Grid;
		var minimum = grid.SubgridSize( 4 );
		var splay = Owner.FindArchetype( building?.Archetype )?.DriveSplay ?? approachDraft.Splay;
		var run = Vector2.Dot( drag, outward );

		if ( run < minimum )
		{
			return null;
		}

		if ( approach == ApproachKind.Ramp )
		{
			return ArchApproachPlacement.Seat(
				wall, room, building, Owner.Kit, along, ApproachKind.Ramp,
				approachDraft.Width, run, approachDraft.Kerbs, approachDraft.Rails, splay );
		}

		var widthDrag = Vector2.Dot( drag, wallDirection );
		var width = MathF.Abs( widthDrag ) < minimum ? approachDraft.Width : MathF.Abs( widthDrag );

		return ArchApproachPlacement.Seat(
			wall, room, building, Owner.Kit, along + widthDrag * 0.5f, ApproachKind.Steps,
			width, run, approachDraft.Kerbs, approachDraft.Rails, splay );
	}

	bool PlaceApproach( Vector2 from, Vector2 to )
	{
		if ( Draft( from, to, out var room ) is not { } draft )
		{
			return true;
		}

		draft.Id = Owner.Plan.AllocateId();
		draft.Name = $"{draft.Kind}{room.Approaches.Count + 1}";

		room.Approaches.Add( draft );
		FinishDraft( draft.Id );
		Owner.Commit( $"Place {draft.Kind}" );

		return true;
	}

	// ---- The walk: one click per point the stair turns or arrives at ----

	float ChainRise() => snapStoreys ? stories * Owner.StoreyHeight : MathF.Max( 1f, flightRise );

	ArchStairPart FlightPart( ArchStairCore core, List<ArchStairLane> lanes, ArchRoom room )
	{
		return new ArchStairPart
		{
			BaseHeight = room?.BaseHeight ?? Owner.LevelHeight,
			Core = core,
			Lanes = lanes,
			Width = draft.Width,
			StepRise = draft.StepRise,
			StepGoing = draft.StepGoing,
			WellGap = draft.WellGap,
			Support = draft.Support,
			Under = draft.Under,
			Guard = draft.Guard,
			WallRail = draft.WallRail,
			Wrap = draft.Wrap,
			WrapThickness = draft.WrapThickness
		};
	}

	// Where the ray actually lands, seated on the surface it hit and snapped the way a pipe node is - so a click
	// on the floor above IS the climb, and the stair never has to be told how many storeys it reaches.
	bool Aimed( out Vector3 point )
	{
		var ray = Gizmo.CurrentRay;

		if ( Owner.FacesUnder( ray ).FirstOrDefault() is { } face )
		{
			var facing = Vector3.Dot( face.Normal, ray.Forward );

			if ( MathF.Abs( facing ) > 0.0001f )
			{
				var along = Vector3.Dot( face.Centre - ray.Position, face.Normal ) / facing;

				return Seated( ray.Position + ray.Forward * along, out point );
			}
		}

		var trace = Owner.Scene.Trace.Ray( ray.Position, ray.Position + ray.Forward * 32768f ).Run();

		if ( trace.Hit )
		{
			return Seated( trace.EndPosition, out point );
		}

		// A blockout with nothing built in it yet still has to be walkable, so the work plane is the last resort
		// rather than the only surface.
		if ( Owner.Cursor( out var cursor ) )
		{
			point = new Vector3( cursor.Plan.x, cursor.Plan.y, cursor.Height );

			return true;
		}

		point = default;

		return false;
	}

	// The grid and the wall snap are the tool's own two, off the toolbar - the stair had a third of each and
	// authored on a ladder nothing in the viewport had drawn.
	bool Seated( Vector3 hit, out Vector3 point )
	{
		var flat = Owner.Snap( new Vector2( hit.x, hit.y ) );

		if ( Owner.SnapsToWalls && NearestWall( flat, out var distance, out var onFace ) is not null
			&& distance < ArchWallSnap.Reach( Owner.WallSnapReach ) )
		{
			flat = onFace;
		}

		point = new Vector3( flat.x, flat.y, ArchGridService.Fine( hit.z ) );

		return true;
	}

	// The stair a walk starting here would pick up rather than stand beside.
	ArchStairPart Continued( Vector3 point )
	{
		var reach = MathF.Max( 24f, Owner.Kit.GridSize );

		foreach ( var room in Owner.Plan.AllRooms() )
		{
			foreach ( var stair in room.Stairs )
			{
				if ( ArchStairWalk.Head( stair, out var head ) && (head - point).Length <= reach )
				{
					return stair;
				}
			}
		}

		return null;
	}

	// One resolve for the ghost and the commit alike: a preview that worked the steps out its own way would show
	// a stair the release then built somewhere else.
	ArchStairPart Previewed( IReadOnlyList<Vector3> line, ArchRoom room )
	{
		if ( continuing is null )
		{
			var walked = ArchStairWalk.Sketch( line, draft.Width, ChainRise() );

			return walked.Stands ? FlightPart( walked.Core, walked.Lanes, room ) : null;
		}

		var copy = FlightPart( continuing.Core.Copy(), continuing.Lanes.Select( lane => lane.Copy() ).ToList(), room );

		copy.BaseHeight = continuing.BaseHeight;

		return ArchStairWalk.Extend( copy, line, draft.Width ) ? copy : null;
	}

	protected override void DrawFreeHover()
	{
		if ( !Aimed( out var point ) )
		{
			return;
		}

		var line = new List<Vector3>( walk ) { point };

		foreach ( var node in line )
		{
			Gizmo.Draw.LineSphere( node, 4f );
		}

		for ( var index = 0; index + 1 < line.Count; index++ )
		{
			Gizmo.Draw.Line( line[index], line[index + 1] );
		}

		if ( walk.Count == 0 )
		{
			ArchGhost.Note( point, Continued( point ) is { } stair ? $"continue {stair.Name}" : "click the foot of the stair" );

			return;
		}

		var room = HostRoom( new Vector2( line[0].x, line[0].y ) );

		if ( room is null || Previewed( line, room ) is not { } preview )
		{
			ArchGhost.Note( point, room is null
				? "no building here — walk the stair inside or against one"
				: "walk further — a flight needs a run" );

			return;
		}

		var shape = ArchStairShape.Resolve( preview, room, Owner.Kit );
		var flights = preview.Lanes.Count( lane => lane.Climbs );
		var pads = preview.Lanes.Count - flights;

		ArchStairGhost.Flight( shape, room.BaseHeight + Owner.Kit.WallHeight );
		ArchGhost.Note( point, $"{flights} flight{(flights == 1 ? "" : "s")}"
			+ (pads > 0 ? $" · {pads} platform{(pads == 1 ? "" : "s")}" : "")
			+ $" · {preview.TotalRise:0} climb" );
	}

	protected override void OnFreeClick()
	{
		if ( !Aimed( out var point ) )
		{
			return;
		}

		if ( walk.Count == 0 )
		{
			continuing = Continued( point );

			if ( continuing is not null && ArchStairWalk.Head( continuing, out var head ) )
			{
				walk.Add( head );

				return;
			}
		}

		Owner.EnsureTarget();
		walk.Add( point );
	}

	void FinishWalk()
	{
		if ( walk.Count < 2 )
		{
			CancelWalk();

			return;
		}

		if ( continuing is not null )
		{
			ExtendWalk();

			return;
		}

		if ( HostRoom( new Vector2( walk[0].x, walk[0].y ) ) is not { } room )
		{
			Log.Info( "Architecture: the walk is outside every building — start it inside or against one so the stair has a room to stand in." );
			CancelWalk();

			return;
		}

		if ( Previewed( walk, room ) is not { } stair )
		{
			Log.Info( $"Architecture: that walk is too short — a flight needs {ArchStairLanes.MinLane:0} inches of run." );
			CancelWalk();

			return;
		}

		stair.Id = Owner.Plan.AllocateId();
		stair.Name = $"Stair{room.Stairs.Count + 1}";
		stair.Core.Fill = fill;

		ArchStairLanes.Number( Owner.Plan, stair );

		room.Stairs.Add( stair );

		if ( cutStairwell )
		{
			ArchStairShape.Pierce( Owner.Plan, Owner.Plan.OwnerOf( room ) ?? Owner.ActiveBuilding(), room, stair, Owner.Kit );
		}

		FinishDraft( stair.Id );
		Owner.Picked = new ArchSelection { Item = stair, Room = room, Building = Owner.Plan.OwnerOf( room ) };
		Drew( stair );
		Owner.Commit( "Walk Stair" );
		CancelWalk();

		if ( designs )
		{
			ArchStairWindow.Open( Owner, stair );
		}
	}

	void ExtendWalk()
	{
		if ( !ArchStairWalk.Extend( continuing, walk, draft.Width ) )
		{
			CancelWalk();

			return;
		}

		ArchStairLanes.Number( Owner.Plan, continuing );

		FinishDraft( continuing.Id );
		Owner.Commit( "Continue Stair" );
		CancelWalk();
	}

	void CancelWalk()
	{
		walk.Clear();
		continuing = null;
	}

	protected override void OnClick( Vector2 point )
	{
		if ( approach != ApproachKind.Ramp || !splineDriveway )
		{
			return;
		}

		if ( drivewayStart is null )
		{
			if ( ResolveDrivewayConnection( point, out drivewayStart ) )
			{
				drivewayNodes.Add( DrivewayNode( drivewayStart.Position ) );
			}

			return;
		}

		if ( ResolveDrivewayConnection( point, out var end ) && end.Kind != drivewayStart.Kind )
		{
			drivewayNodes.Add( DrivewayNode( end.Position ) );
			drivewayEnd = end;
			return;
		}

		drivewayNodes.Add( DrivewayNode( point ) );
		drivewayEnd = null;
	}

	protected override void DrawHover( Vector2 point )
	{
		if ( approach != ApproachKind.Ramp || !splineDriveway )
		{
			base.DrawHover( point );

			return;
		}

		base.DrawHover( point );

		if ( drivewayStart is null )
		{
			var message = ResolveDrivewayConnection( point, out var connection )
				? $"start at {connection.Kind.ToString().ToLowerInvariant()}"
				: "start on an exterior wall or street edge";

			ArchGhost.Note( new Vector3( point.x, point.y, Owner.LevelHeight ), message );
			return;
		}

		var target = point;
		var completing = ResolveDrivewayConnection( point, out var end ) && end.Kind != drivewayStart.Kind;

		if ( completing )
		{
			target = end.Position;
		}

		var line = drivewayNodes.Select( node => node.Copy() ).ToList();
		line.Add( DrivewayNode( target ) );

		if ( drivewayStart.Kind == DrivewayConnectionKind.Street )
		{
			line.Reverse();
		}

		var curve = ArchCurve.Of( line );
		var frames = curve.Walk( MathF.Max( 8f, Owner.Kit.GridSize * 0.5f ) );

		ArchGhost.Curve( curve );
		DrawDrivewayWidth( frames, approachDraft.Width );

		var note = completing ? $"connect to {end.Kind.ToString().ToLowerInvariant()}" : "click a control point";
		ArchGhost.Note( new Vector3( target.x, target.y, Owner.LevelHeight ), note );
	}

	void FinishSplineDriveway( DrivewayConnection end )
	{
		var wall = drivewayStart.Kind == DrivewayConnectionKind.Wall ? drivewayStart : end;
		var street = drivewayStart.Kind == DrivewayConnectionKind.Street ? drivewayStart : end?.Kind == DrivewayConnectionKind.Street ? end : null;

		if ( wall is null || drivewayNodes.Count < 2 )
		{
			return;
		}

		var line = drivewayNodes.Select( node => node.Copy() ).ToList();

		if ( drivewayStart.Kind == DrivewayConnectionKind.Street )
		{
			line.Reverse();
		}

		var splay = Owner.FindArchetype( wall.Building?.Archetype )?.DriveSplay ?? approachDraft.Splay;
		var placed = ArchApproachPlacement.Seat(
			wall.Wall, wall.Room, wall.Building, Owner.Kit, wall.Along, ApproachKind.Ramp,
			approachDraft.Width, 0f, approachDraft.Kerbs, false, splay );

		line[0].Position = new Vector3( placed.Origin.x, placed.Origin.y, wall.Room.BaseHeight );

		if ( street is not null )
		{
			line[^1].Position = new Vector3( street.Position.x, street.Position.y, 0f );
		}

		GradeSplineDriveway( line, wall, street );
		AlignSplineDrivewayEnds( line, wall.Direction, street?.Direction ?? FreeEndDirection( line ) );

		placed.Id = Owner.Plan.AllocateId();
		placed.Name = $"SplineRamp{wall.Room.Approaches.Count + 1}";
		placed.RoadId = street?.Road.Id ?? 0;
		placed.Nodes = line;
		placed.Run = placed.Curve().Length;

		wall.Room.Approaches.Add( placed );
		FinishDraft( placed.Id );
		Owner.Commit( "Place Spline Driveway" );

		CancelSplineDriveway();
	}

	void TryFinishSplineDriveway()
	{
		if ( drivewayStart is null || drivewayNodes.Count < 2 )
		{
			return;
		}

		if ( drivewayStart.Kind == DrivewayConnectionKind.Street && drivewayEnd?.Kind != DrivewayConnectionKind.Wall )
		{
			return;
		}

		FinishSplineDriveway( drivewayEnd );
	}

	void GradeSplineDriveway( List<ArchCurveNode> line, DrivewayConnection wall, DrivewayConnection street )
	{
		var lift = ArchAsks.Lift( Owner.Plan, wall.Building, Owner.Kit );
		var startHeight = wall.Room.BaseHeight;
		var endPoint = Flat( line[^1] );
		var endHeight = street is not null
			? street.RoadFrame.Position.z + (ArchAnswers.Load().Asks<IArchApproachLanding>()?.Back( street.Road, Owner.Kit ) ?? 0f) - lift
			: ArchTerrain.Sample( Owner.Scene, endPoint, out var terrainHeight ) ? terrainHeight - lift : -lift;
		var distances = new float[line.Count];

		for ( var index = 1; index < line.Count; index++ )
		{
			distances[index] = distances[index - 1] + (Flat( line[index] ) - Flat( line[index - 1] )).Length;
		}

		var length = MathF.Max( 1f, distances[^1] );

		for ( var index = 0; index < line.Count; index++ )
		{
			var position = line[index].Position;
			position.z = ArchGridService.Fine( MathX.Lerp( startHeight, endHeight, distances[index] / length ) );
			line[index].Position = position;
		}
	}

	void AlignSplineDrivewayEnds( List<ArchCurveNode> line, Vector2 wallDirection, Vector2 streetDirection )
	{
		if ( line.Count < 2 )
		{
			return;
		}

		var grid = Owner.Grid;
		var firstReach = MathF.Max( grid.SubgridSize( 4 ), (Flat( line[1] ) - Flat( line[0] )).Length / 3f );
		var lastReach = MathF.Max( grid.SubgridSize( 4 ), (Flat( line[^1] ) - Flat( line[^2] )).Length / 3f );
		var flatLength = MathF.Max( 1f, line.Zip( line.Skip( 1 ), ( from, to ) => (Flat( to ) - Flat( from )).Length ).Sum() );
		var slope = (line[^1].Position.z - line[0].Position.z) / flatLength;

		line[0].Mode = ArchTangentMode.Split;
		line[0].Out = new Vector3( wallDirection.x * firstReach, wallDirection.y * firstReach, slope * firstReach );
		line[^1].Mode = ArchTangentMode.Split;
		line[^1].In = new Vector3( -streetDirection.x * lastReach, -streetDirection.y * lastReach, -slope * lastReach );
	}

	static Vector2 FreeEndDirection( IReadOnlyList<ArchCurveNode> line )
	{
		var direction = Flat( line[^1] ) - Flat( line[^2] );

		return direction.IsNearZeroLength ? new Vector2( 1f, 0f ) : direction.Normal;
	}

	bool ResolveDrivewayConnection( Vector2 point, out DrivewayConnection connection )
	{
		var wallFound = ResolveDrivewayWall( point, out var wall );
		var streetFound = ResolveDrivewayStreet( point, out var street );

		if ( wallFound && (!streetFound || wall.Gap <= street.Gap) )
		{
			connection = wall;
			return true;
		}

		connection = street;

		return streetFound;
	}

	bool ResolveDrivewayWall( Vector2 point, out DrivewayConnection connection )
	{
		connection = null;

		var wall = ArchApproachPlacement.NearestExteriorWall(
			Owner.Plan.Buildings, Owner.Level, point,
			out var building, out var room, out var along, out var distance, out var outward );

		if ( wall is null || distance > MathF.Max( 48f, Owner.Kit.GridSize ) )
		{
			return false;
		}

		var seated = ArchApproachPlacement.Seat(
			wall, room, building, Owner.Kit, along, ApproachKind.Ramp,
			approachDraft.Width, 0f, approachDraft.Kerbs, false, approachDraft.Splay );

		connection = new DrivewayConnection
		{
			Kind = DrivewayConnectionKind.Wall,
			Position = seated.Origin,
			Direction = outward,
			Gap = distance,
			Building = building,
			Room = room,
			Wall = wall,
			Along = along
		};

		return true;
	}

	bool ResolveDrivewayStreet( Vector2 point, out DrivewayConnection connection )
	{
		connection = null;
		var nearest = float.MaxValue;

		foreach ( var road in Owner.Plan.Roads() )
		{
			var curve = Owner.Resolved( road.Id, road.Curve );

			if ( !curve.Nearest( point, out var frame, out var centreGap ) )
			{
				continue;
			}

			var across = new Vector2( frame.Across.x, frame.Across.y );
			var right = Vector2.Dot( point - frame.Flat, across ) >= 0f;
			var reach = road.Reach( right, frame.WidthScale );
			var edgeGap = MathF.Abs( centreGap - reach );

			if ( centreGap > reach + MathF.Max( 48f, Owner.Kit.GridSize ) || edgeGap >= nearest )
			{
				continue;
			}

			var station = Owner.Grid.Subgrid( frame.Distance, 4 );

			if ( !curve.Sample( station, out frame ) )
			{
				continue;
			}

			var side = right ? 1f : -1f;
			var edge = frame.Side( side * road.Reach( right, frame.WidthScale ) );

			nearest = edgeGap;
			connection = new DrivewayConnection
			{
				Kind = DrivewayConnectionKind.Street,
				Position = ArchGridService.Fine( new Vector2( edge.x, edge.y ) ),
				Direction = right ? -across : across,
				Gap = edgeGap,
				Road = road,
				RoadFrame = frame
			};
		}

		return connection is not null;
	}

	ArchCurveNode DrivewayNode( Vector2 point )
	{
		var snapped = Owner.Grid.CurveControl( new Vector3( point.x, point.y, Owner.LevelHeight ) );

		return ArchCurveNode.At( snapped );
	}

	void CancelSplineDriveway()
	{
		drivewayNodes.Clear();
		drivewayStart = null;
		drivewayEnd = null;
	}

	static Vector2 Flat( ArchCurveNode node ) => new( node.Position.x, node.Position.y );

	static void DrawDrivewayWidth( IReadOnlyList<ArchFrame> frames, float width )
	{
		for ( var index = 0; index < frames.Count - 1; index++ )
		{
			var fromHalf = MathF.Max( 12f, width * frames[index].WidthScale ) * 0.5f;
			var toHalf = MathF.Max( 12f, width * frames[index + 1].WidthScale ) * 0.5f;

			Gizmo.Draw.Line( frames[index].Side( -fromHalf ), frames[index + 1].Side( -toHalf ) );
			Gizmo.Draw.Line( frames[index].Side( fromHalf ), frames[index + 1].Side( toHalf ) );
		}
	}

	protected override void DrawPreview()
	{
		if ( carving )
		{
			DrawCarvePreview();
			return;
		}

		DrawApproachPreview();
	}

	protected override void OnDrag( Vector2 from, Vector2 to )
	{
		if ( carving )
		{
			CarveSteps( from, to );
			return;
		}

		PlaceApproach( from, to );
	}

	void DrawCarvePreview()
	{
		var drag = ArchCarveHosts.Aim( Owner.Plan, Owner.Level, DragStart, DragCurrent );
		var note = new Vector3( DragCurrent.x, DragCurrent.y, drag.Host?.TopHeight ?? Owner.LevelHeight );

		if ( !drag.Lands )
		{
			ArchGhost.Plate( Min( DragStart, DragCurrent ), Max( DragStart, DragCurrent ), Owner.LevelHeight, 8 );
			ArchGhost.Note( note, "nothing here to carve into — stand a platform first" );

			return;
		}

		ArchGhost.Ring( drag.Host.Outline(), drag.Host.TopHeight );

		if ( ArchStairCarve.Sketch( drag, Owner.Kit, draft ) is not { } carved )
		{
			ArchGhost.Note( note, $"{drag.Host.Name} — drag further to carve steps into it" );
			return;
		}

		// Off the aimed drag, so the ghost climbs as the commit will, even dragged downhill.
		ArchStairGhost.Flight( ArchStairCarve.Resolve( drag.Host, carved, Owner.ActiveBuilding(), Owner.Kit ), drag.Host.TopHeight );
		ArchGhost.Note( note, $"Concrete steps · {carved.Lanes.Count} flight carved into {drag.Host.Name}" );
	}

	void CarveSteps( Vector2 from, Vector2 to )
	{
		var drag = ArchCarveHosts.Aim( Owner.Plan, Owner.Level, from, to );

		if ( !drag.Lands )
		{
			Log.Info( "Architecture: Concrete steps are cut OUT of a platform's concrete — stand one with Boolean Modifiers first, then drag across it." );
			return;
		}

		if ( ArchStairCarve.Cut( Owner.Plan, drag, Owner.Kit, draft ) is not { } carved )
		{
			Log.Info( $"Architecture: that run is shorter than {ArchStairCarve.MinRun} inches, so there is nothing to cut." );
			return;
		}

		FinishDraft( carved.Id );
		Owner.Commit( "Carve Steps" );
	}

	void PunchToTop()
	{
		var building = Owner.ActiveBuilding();

		if ( building is null )
		{
			return;
		}

		var top = building.Rooms.Count > 0 ? building.Rooms.Max( entry => entry.Floor ) : Owner.Level;
		var head = ArchStairShape.FloorOf( building, Owner.Kit, top + 1 );

		flightRise = MathF.Max( 1f, head - Owner.LevelHeight );
		stories = Math.Max( 1, (int)MathF.Round( flightRise / MathF.Max( 1f, Owner.StoreyHeight ) ) );

		Refresh();
	}

	// The near FACE, not the centreline: a step snapped to the middle of a wall stands half inside it, and
	// the run comes out short by a thickness at whichever end caught.
	ArchWall NearestWall( Vector2 point, out float distance, out Vector2 onFace )
	{
		distance = float.MaxValue;
		onFace = default;
		ArchWall best = null;

		foreach ( var room in Owner.Plan.AllRooms().Where( entry => entry.Floor == Owner.Level ) )
		{
			foreach ( var wall in room.Walls )
			{
				var span = wall.End - wall.Start;
				var length = span.Length;

				if ( length < 1f )
				{
					continue;
				}

				var t = Math.Clamp( Vector2.Dot( point - wall.Start, span ) / (length * length), 0f, 1f );
				var at = wall.Start + span * t;
				var side = point - at;
				var thickness = wall.Thickness > 0.1f ? wall.Thickness : Owner.Kit.WallThickness;
				var face = at + (side.IsNearZeroLength ? wall.Normal : side.Normal) * thickness * 0.5f;
				var gap = (face - point).Length;

				if ( gap < distance )
				{
					distance = gap;
					onFace = face;
					best = wall;
				}
			}
		}

		return best;
	}

	// Against an exterior wall the stair can start outside the footprint — file to that wall's room. Outdoors,
	// with no wall anywhere near, it still has to land somewhere: the room on this level, else the nearest one
	// the plan has, because a yard stair between two retaining walls belongs to no room at all.
	ArchRoom HostRoom( Vector2 point )
	{
		var snap = MathF.Max( 48f, Owner.Kit.GridSize );

		if ( ArchApproachPlacement.NearestExteriorWall( Owner.Plan.Buildings, Owner.Level, point,
			out var building, out var room, out _, out var distance, out _ ) is not null && distance < snap && room is not null )
		{
			return room;
		}

		return Owner.RoomOnLevel( point ) ?? Owner.ActiveRoom();
	}

	protected override void BuildOptions( ToolSidebarWidget panel )
	{
		ArchSidebarSection.Show( panel, Scope( "kind" ), "Kind", group =>
		{
			using var grid = ArchIconGrid.In( group );

			grid.Pick( "Stair — click the foot, then each point it turns or arrives at; the clicks are the pattern", "stair_straight", "stairs", walking,
				() => Mode( () => walking = true ) );

			grid.Pick( "Concrete steps — cut a flight OUT of a platform you already stood", "stair_carve", "content_cut", carving,
				() => Mode( () => carving = true ) );

			grid.Pick( "Foundation steps — climb from grade onto the plinth at a door", "approach_steps", "stairs_2",
				approach == ApproachKind.Steps, () => Mode( () => approach = ApproachKind.Steps ) );

			grid.Pick( "Driveway ramp — slope from grade onto the plinth at a garage", "approach_ramp", "trending_up",
				approach == ApproachKind.Ramp, () => Mode( () => approach = ApproachKind.Ramp ) );
		} );

		if ( walking )
		{
			BuildWalkOptions( panel );
			BuildPicked( panel );

			return;
		}

		// A carved flight has none of the rest - only width and riser survive the cut.
		if ( carving )
		{
			ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
			{
				group.Add( ArchPartUi.Number( "Width", draft.Width, Owner.Kit.StairWidth, value => draft.Width = value ) );
				group.Add( ArchPartUi.Number( "Riser", draft.StepRise, Owner.Kit.StepRise, value => draft.StepRise = value ) );
				group.Add( ArchPartUi.Number( "Going", draft.StepGoing, Owner.Kit.StepGoing, value => draft.StepGoing = value ) );
			} );

			panel.Layout.Add( ArchSidebarLayout.Advice( "The riser bends to fit the platform's own rise, so a flight always arrives level with the coping." ) );

			return;
		}

		approachDraft.Kind = approach ?? ApproachKind.Steps;

		if ( approachDraft.Kind == ApproachKind.Ramp )
		{
			ArchSidebarSection.Show( panel, Scope( "path" ), "Path", group =>
			{
				using var path = ArchIconGrid.In( group );

				path.Pick( "Straight — locked square to the supporting wall", "driveway_straight", "horizontal_rule",
					!splineDriveway, () => { CancelSplineDriveway(); splineDriveway = false; Refresh(); } );
				path.Pick( "Spline — snapped control points between a wall and street", "driveway_spline", "gesture",
					splineDriveway, () => { CancelSplineDriveway(); splineDriveway = true; Refresh(); } );
			} );
		}

		ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
		{
			if ( approachDraft.Kind == ApproachKind.Ramp && splineDriveway )
			{
				group.Add( ArchPartUi.Number( "Width", approachDraft.Width, 96f,
					value => approachDraft.Width = Owner.Grid.Subgrid( value, 4 ) ) );
			}

			ArchSidebarLayout.Form( group, form => ArchStairUi.Fittings( form, approachDraft, null ) );
		} );
	}

	// One place clears every other mode, so a kind pick can never leave two of them live.
	void Mode( Action chosen )
	{
		CancelSplineDriveway();
		CancelWalk();

		walking = false;
		carving = false;
		approach = null;

		chosen();
		Refresh();
	}

	// The stair under the cursor is edited through the same sheet the layer stack shows, so its balustrade,
	// its wall rails and every step's own climb are one click away from the tool that walked it.
	void BuildPicked( ToolSidebarWidget panel )
	{
		if ( Owner.Picked is not { Item: ArchStairPart } picked )
		{
			return;
		}

		ArchSidebarSection.Show( panel, Scope( "selected" ), $"Selected — {picked.Describe()}", group =>
			group.Add( new Button( "Deselect", "close" ) { Clicked = () => { Owner.Select( null ); Refresh(); } } ) );

		ArchPartUi.Sheet( panel, Owner, picked, Refresh );
	}

	void BuildWalkOptions( ToolSidebarWidget panel )
	{
		SeedRise();

		ArchSidebarSection.Show( panel, Scope( "placement" ), "Placement", group =>
		{
			group.Add( ArchPartUi.Number( "Flight width", draft.Width, Owner.Kit.StairWidth, value => draft.Width = MathF.Max( ArchStairLanes.MinLane, value ) ) );
			group.Add( ArchPartUi.Number( "Riser", draft.StepRise, Owner.Kit.StepRise, value => draft.StepRise = value ) );
			group.Add( ArchPartUi.Number( "Going", draft.StepGoing, Owner.Kit.StepGoing, value => draft.StepGoing = value ) );

			if ( snapStoreys )
			{
				group.Add( ArchPartUi.Integer( "Climbs floors", stories, 1, value => stories = Math.Clamp( value, 1, 12 ) ) );
			}
			else
			{
				var baseHeight = Owner.LevelHeight;

				group.Add( ArchPartUi.Number( "Head", baseHeight + flightRise, baseHeight + Owner.StoreyHeight,
					value => flightRise = MathF.Max( 1f, value - baseHeight ) ) );
			}

			// Stays out of Advanced because it decides which of those two rows is above it - a toggle that
			// re-lays out the group it is in cannot be the one thing folded away from it.
			using var grid = ArchIconGrid.In( group );

			grid.Toggle( "Snap to storeys — the climb is whole storeys", "bp_storeys", "stairs", snapStoreys, value =>
			{
				snapStoreys = value;
				Refresh();
			} );

			grid.Toggle( "Solid — every flight a stepped mass on the shaft floor, the concrete stair", "bp_solid", "foundation", fill == StairFill.Solid, value =>
			{
				fill = value ? StairFill.Solid : StairFill.Open;
				Refresh();
			} );
		} );

		ArchSidebarSection.Disclosure( panel, Scope( "assembly" ), "Assembly", true, group =>
			ArchSidebarLayout.Form( group, form => ArchStairUi.Build( form, draft, Refresh, null ) ) );

		ArchSidebarSection.Disclosure( panel, Scope( "advanced" ), "Advanced", true, group =>
		{
			using ( var grid = ArchIconGrid.In( group ) )
			{
				grid.Toggle( "Cut a stairwell through every floor the climb reaches", "opt_pierce_floor", "crop_free", cutStairwell, value => cutStairwell = value );

				grid.Toggle( "Open the drawing on the stair just walked", "opt_designer", "draw", designs, value => designs = value );
			}

			var flow = group.AddRow();
			flow.Spacing = 4;
			flow.Add( new Button( "To the top", "vertical_align_top" ) { Clicked = PunchToTop } );
		} );

		panel.Layout.Add( ArchSidebarLayout.Advice(
			"Every flight's climb is divided evenly. Select the stair and pull a step's LIFT ARROW to pin that one — three risers onto a broad landing, fourteen more after it — and the rest share what is left." ) );
	}

	// The climb defaults to one storey; the box only ever sets the footprint.
	void SeedRise()
	{
		if ( seededRise )
		{
			return;
		}

		flightRise = Owner.StoreyHeight;
		seededRise = true;
	}

	string WalkAdvice()
	{
		if ( continuing is not null )
		{
			return $"Walking {continuing.Name} on from its head. Click where it turns or arrives and press Enter — the new flights turn against the one below them.";
		}

		var destination = snapStoreys ? $"climbs to floor {Owner.Level + stories + 1}" : $"{ChainRise():0} climb";

		return $"Click the foot, then each point the stair turns or arrives at, and press Enter. Every leg is a flight and every turn drops a platform in — click the floor above and it {destination} on its own. Start on a standing stair's head to walk it further.";
	}
}