Editor/Stair/ArchStairCanvas.cs

An editor UI widget that draws and edits a stair plan. It renders plan/elevation/profile views, paints rooms, walls, steps, rails and interactive grips, and handles mouse input for selecting, dragging and joining stair elements.

File AccessReflection
using System;
using System.Collections.Generic;
using Editor;

namespace Sunless.Architecture;

// Which way the drafting pane looks. Stated in the STAIR's own frame rather than the world's, because a plan of a
// stair turned 37 degrees is a plan of a stair turned 37 degrees - and every number a grip writes is already in
// that frame, so nothing has to be projected back out of world space.
public enum ArchStairView
{
	Plan,
	Along,
	Across
}

enum ArchStairGrip
{
	Head,
	Left,
	Right,
	Lift,
	Body,
	Bend,
	Rail,
	RailFoot,
	RailHead
}

// The drawing's colours in one place. A plan, an elevation and the profile strip all say the same things and have to
// say them the same way: what the stair stands IN is orange, the floors it climbs BETWEEN are red, a flight is blue
// and a platform is amber - so a glance at any pane reads the same as a glance at the others.
static class ArchStairInk
{
	public static readonly Color Walls = new( 0.92f, 0.55f, 0.18f );
	public static readonly Color Floors = new( 0.88f, 0.26f, 0.22f );
	public static readonly Color Flight = new( 0.30f, 0.58f, 0.82f );
	public static readonly Color Platform = new( 0.86f, 0.66f, 0.26f );
	public static readonly Color Balustrade = new( 0.94f, 0.84f, 0.36f );
	public static readonly Color Handrail = new( 0.62f, 0.78f, 0.52f );
	public static readonly Color Lift = new( 0.52f, 0.86f, 0.42f );
	public static readonly Color Reach = new( 0.42f, 0.72f, 0.92f );
	public static readonly Color Head = new( 0.74f, 0.54f, 0.94f );
	public static readonly Color Bend = new( 0.88f, 0.46f, 0.72f );
	public static readonly Color Flow = new( 0.80f, 0.88f, 0.96f );
}

// One orthographic pane over one stair. Hand painted rather than rendered: this is a DRAWING - dimension strings,
// numbered treads, an edge you grab - and a shaded render of the same geometry answers none of those questions.
public sealed class ArchStairCanvas : Widget
{
	const float Padding = 28f;
	const float GripSize = 9f;
	const float BandHeight = 142f;

	// How far past the head the bend grip stands, in the shaft's own units - far enough to clear the head square
	// whatever the drawing is zoomed to, because two grips in one place is one grip.
	const float Elbow = 20f;

	// Finer than the grid's own fifteen: a swept segment is a share of a turn, and a curve of six segments through a
	// right angle wants five degree steps to be aimed by hand at all.
	const float AngleStep = 5f;

	// A join station is a target rather than a handle, so it takes a press from further out than a grip does.
	const float StationReach = 6f;

	readonly ArchTool tool;
	readonly Func<ArchStairPart> subject;
	readonly Action changed;
	readonly Action committed;

	readonly List<(ArchStairGrip Kind, int Step, int Guard, Vector2 At)> grips = new();

	// Where each step lands in the profile strip, so the walked drawing picks a step the way the plan does.
	readonly List<(int Step, Rect At)> walked = new();

	// The head of each step in the profile, as a point you drag. The profile is where the climb is READ - what a
	// step arrives at, against the floor it is meant to meet - so it is where the climb has to be settable.
	readonly List<(int Step, Vector2 At)> pins = new();

	float scale = 1f;
	Vector2 origin;

	Vector2 bandFoot;
	float bandZoom = 1f;

	int held = -1;
	int heldGuard = -1;
	ArchStairGrip heldKind;
	bool dragging;

	// A lift being dragged in the PROFILE rather than in an elevation: the same question asked of a different
	// drawing, so the height is read off the band's own scale instead of the page's.
	bool profiling;

	// The step waiting to be told where it joins, and where the cursor last was. Joining is a two-part gesture -
	// name the step, then point at the spot on the step below - so it has to be remembered between clicks.
	int joining = -1;
	Vector2 hover;

	// Resolved once when the gesture starts and reused for its whole life. Subject() walks every building, room,
	// porch and platform in the plan, and asking it again per mouse-move is that walk sixty times a second.
	ArchStairPart holding;

	// The room the stair is filed on, found once per paint for the same reason: it is a walk of every room in the
	// plan, and the frame, the rooms, the walls and the storey lines all ask for it.
	ArchRoom home;

	public ArchStairView View { get; set; } = ArchStairView.Plan;

	// The side profile under the drawing. On by default because the question it answers - does this climb arrive at
	// the floor it is meant to - is the one a plan cannot answer at all.
	public bool Profile { get; set; } = true;

	// The building the stair stands in, drawn behind it: the room it is in, the walls of its storey and the floors
	// it climbs between. On by default, because a stair judged on its own cannot say whether it lands in a doorway
	// or arrives a foot under the slab, which is most of what the drawing is for.
	public bool Surrounds { get; set; } = true;

	// The drawing opens on the first step rather than on nothing: only the picked step offers its widgets, so a
	// drawing with nothing picked is a drawing with nothing to grab.
	public int Picked { get; set; }

	// The railing held, if one is. A railing is its own thing, so it is picked and dragged on its own terms
	// rather than as a property of the step it happens to stand on.
	public int PickedGuard { get; set; } = -1;

	public Action<int> Selected { get; set; }

	public ArchStairCanvas( Widget parent, ArchTool tool, Func<ArchStairPart> subject, Action changed, Action committed ) : base( parent )
	{
		this.tool = tool;
		this.subject = subject;
		this.changed = changed;
		this.committed = committed;

		MinimumSize = new Vector2( 420, 320 );
		MouseTracking = true;
		FocusMode = FocusMode.Click;
	}

	// ---- The projection ----

	// Local coordinates are the shaft's own: along, across, and height off the stair's foot. Every view is two of
	// those three, so a grip dragged in any pane writes the same numbers without a change of frame.
	Vector2 Flat( float along, float across, float lift ) => View switch
	{
		ArchStairView.Along => new Vector2( along, -lift ),
		ArchStairView.Across => new Vector2( across, -lift ),
		_ => new Vector2( along, across )
	};

	Vector2 Screen( Vector2 flat ) => origin + flat * scale;

	Vector2 Local( Vector2 screen ) => (screen - origin) / MathF.Max( 0.0001f, scale );

	void Frame( ArchStairPart stair, Rect page )
	{
		var core = stair.Core;
		var climb = stair.TotalRise;

		var corners = new List<Vector2>
		{
			Flat( 0f, 0f, 0f ),
			Flat( core.Length, core.Width, 0f ),
			Flat( 0f, 0f, climb ),
			Flat( core.Length, core.Width, climb )
		};

		// The room the stair stands in is framed with it, so the drawing opens on the stair AND on what it has to
		// fit into. Only the room - framing the whole plan would shrink the thing being worked on to a smudge.
		if ( Surrounds && View == ArchStairView.Plan )
		{
			foreach ( var point in Standing( stair ) )
			{
				corners.Add( Flat( point.x, point.y, 0f ) );
			}
		}

		var min = corners[0];
		var max = corners[0];

		foreach ( var corner in corners )
		{
			min = new Vector2( MathF.Min( min.x, corner.x ), MathF.Min( min.y, corner.y ) );
			max = new Vector2( MathF.Max( max.x, corner.x ), MathF.Max( max.y, corner.y ) );
		}

		var span = max - min;
		var room = new Vector2( MathF.Max( 1f, page.Width - Padding * 2f ), MathF.Max( 1f, page.Height - Padding * 2f ) );

		scale = MathF.Min( room.x / MathF.Max( 1f, span.x ), room.y / MathF.Max( 1f, span.y ) );
		origin = new Vector2( page.Left + Padding, page.Top + Padding ) - min * scale
			+ (room - span * scale) * 0.5f;
	}

	// ---- Painting ----

	protected override void OnPaint()
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground.Darken( 0.25f ) );
		Paint.DrawRect( LocalRect );

		if ( subject() is not { Core: not null } stair || stair.Lanes.Count == 0 )
		{
			Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
			Paint.DrawText( LocalRect, "no stair", TextFlag.Center );

			return;
		}

		var band = Strip();

		grips.Clear();
		walked.Clear();
		pins.Clear();

		home = ArchStairSteps.Home( tool?.Plan, stair );

		Frame( stair, new Rect( 0f, 0f, Width, Height - band.Height ) );

		PaintContext( stair );
		PaintShaft( stair );

		// Hoisted, not asked per row: both walk every step, so reaching through one inside the loop is how an
		// honest table becomes an n-squared allocation sixty times a second.
		var climbs = ArchStairLanes.Climbs( stair.Core, stair.Lanes );
		var bases = ArchStairEdges.Bases( stair );

		for ( var index = 0; index < stair.Lanes.Count; index++ )
		{
			PaintStep( stair, index, climbs, bases );
		}

		PaintFlow( stair, climbs, bases );
		PaintGrips();
		PaintLegend();
		PaintJoining( stair );

		if ( band.Height > 1f )
		{
			PaintProfile( stair, band, climbs, bases );
		}
	}

	Rect Strip() => Profile && Height > BandHeight * 2f
		? new Rect( 0f, Height - BandHeight, Width, BandHeight )
		: new Rect( 0f, Height, Width, 0f );

	// What the stair STANDS IN, drawn behind it: the room and the walls of its storey in plan, the floors it climbs
	// between in an elevation. A stair judged on its own tells you nothing about whether it lands in a doorway or
	// arrives a foot under the slab, which is most of what the drawing is for.
	void PaintContext( ArchStairPart stair )
	{
		if ( tool?.Plan is null || !Surrounds )
		{
			return;
		}

		if ( View == ArchStairView.Plan )
		{
			PaintRooms( stair );
			PaintWalls( stair );

			return;
		}

		PaintStoreys( stair );
	}

	// The storey the stair stands on is the floor of the room it is FILED on. The tool's own level says where the
	// author is working, and a drawing opened on a stair two floors up would then show a storey it never touches.
	int Storey => home?.Floor ?? tool?.Level ?? 0;

	// The room's own footprint, in the shaft's numbers.
	IEnumerable<Vector2> Standing( ArchStairPart stair )
	{
		if ( home is not { HasFootprint: true } room )
		{
			return Array.Empty<Vector2>();
		}

		return room.Footprint.Select( point => ArchStairLanes.Local( stair.Core, point ) );
	}

	void PaintRooms( ArchStairPart stair )
	{
		var core = stair.Core;
		var level = Storey;

		foreach ( var room in tool.Plan.AllRooms().Where( candidate => candidate.Floor == level && candidate.HasFootprint ) )
		{
			var loop = room.Footprint
				.Select( point => ArchStairLanes.Local( core, point ) )
				.Select( local => Screen( Flat( local.x, local.y, 0f ) ) )
				.ToArray();

			Paint.ClearPen();
			Paint.SetBrush( ArchStairInk.Floors.WithAlpha( 0.07f ) );
			Paint.DrawPolygon( loop );

			Paint.ClearBrush();
			Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.4f ), 1f );

			for ( var corner = 0; corner < loop.Length; corner++ )
			{
				Paint.DrawLine( loop[corner], loop[(corner + 1) % loop.Length] );
			}
		}
	}

	// Walls drawn with the thickness they are built at, so a flight arriving against one reads as arriving against
	// a wall rather than crossing a line.
	void PaintWalls( ArchStairPart stair )
	{
		var core = stair.Core;
		var thickness = MathF.Max( 1f, tool.Kit.WallThickness );

		Paint.ClearPen();
		Paint.SetBrush( ArchStairInk.Walls.WithAlpha( 0.5f ) );

		foreach ( var wall in tool.Plan.WallsOn( Storey ) )
		{
			var from = ArchStairLanes.Local( core, wall.Start );
			var to = ArchStairLanes.Local( core, wall.End );
			var span = to - from;

			if ( span.IsNearZeroLength )
			{
				continue;
			}

			var half = new Vector2( -span.Normal.y, span.Normal.x ) * (wall.Thickness > 0f ? wall.Thickness : thickness) * 0.5f;

			Paint.DrawPolygon(
				Screen( Flat( from.x - half.x, from.y - half.y, 0f ) ),
				Screen( Flat( to.x - half.x, to.y - half.y, 0f ) ),
				Screen( Flat( to.x + half.x, to.y + half.y, 0f ) ),
				Screen( Flat( from.x + half.x, from.y + half.y, 0f ) ) );
		}
	}

	// The slabs the climb passes, as the level lines an elevation is read against.
	void PaintStoreys( ArchStairPart stair )
	{
		Paint.SetDefaultFont( 7 );

		foreach ( var (level, lift) in Floors( stair ) )
		{
			var line = Screen( Flat( 0f, 0f, lift ) ).y;

			Paint.ClearBrush();
			Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.5f ), 1f );
			Paint.DrawLine( new Vector2( 0f, line ), new Vector2( Width, line ) );

			Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.85f ) );
			Paint.DrawText( new Rect( 4f, line - 12f, 120f, 12f ), $"floor {level} · {stair.BaseHeight + lift:0}", TextFlag.LeftTop );
		}
	}

	// Every slab the climb passes, measured in the stair's own lift. One answer for the elevation's level lines and
	// the profile's, so the two drawings cannot disagree about which floor the stair arrives at.
	IEnumerable<(int Level, float Lift)> Floors( ArchStairPart stair )
	{
		var storey = MathF.Max( 1f, tool.StoreyHeight );
		var clearance = tool.Kit.GroundClearance;
		var lowest = (int)MathF.Floor( (stair.BaseHeight - clearance) / storey );
		var reach = lowest + (int)MathF.Ceiling( stair.TotalRise / storey ) + 1;

		for ( var level = lowest; level <= reach; level++ )
		{
			yield return (level, level * storey + clearance - stair.BaseHeight);
		}
	}

	void PaintShaft( ArchStairPart stair )
	{
		var core = stair.Core;
		var climb = stair.TotalRise;

		var a = Screen( Flat( 0f, 0f, 0f ) );
		var b = Screen( Flat( core.Length, core.Width, View == ArchStairView.Plan ? 0f : climb ) );

		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground.WithAlpha( 0.35f ) );
		Paint.DrawRect( Rect.FromPoints( a, b ) );

		Paint.ClearBrush();
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.16f ), 1f );
		Paint.DrawRect( Rect.FromPoints( a, b ) );
	}

	void PaintStep( ArchStairPart stair, int index, float[] climbs, float[] bases )
	{
		var lane = stair.Lanes[index];
		var seat = bases[index];
		var climb = climbs[index];
		var picked = index == Picked;

		var foot = seat - stair.BaseHeight;
		var head = foot + (lane.Climbs ? climb : 0f);
		var body = Body( lane, foot, head );
		var bounds = Rect.FromPoints( body[0], body[0] );

		foreach ( var corner in body )
		{
			bounds.Add( corner );
		}

		Paint.ClearPen();
		Paint.SetBrush( Tint( lane, picked ) );
		Paint.DrawPolygon( body );

		if ( lane.Climbs )
		{
			PaintTreads( stair, lane, index, foot, head );
		}

		Paint.ClearBrush();
		Paint.SetPen( picked ? Theme.Primary : Theme.TextControl.WithAlpha( 0.45f ), picked ? 2f : 1f );

		for ( var corner = 0; corner < body.Length; corner++ )
		{
			Paint.DrawLine( body[corner], body[(corner + 1) % body.Length] );
		}

		PaintLabel( bounds, lane, index, climb );
		PaintRails( lane, index );
		Offer( stair, index, climbs, bases );
	}

	// The step as it actually stands: its four corners in plan, where a swept segment is a turned quad rather than a
	// box, and the rake it cuts against the page in an elevation.
	Vector2[] Body( ArchStairLane lane, float foot, float head )
	{
		if ( View == ArchStairView.Plan )
		{
			return lane.Corners().Select( corner => Screen( Flat( corner.x, corner.y, 0f ) ) ).ToArray();
		}

		var (from, to) = Reach( lane );

		return new[]
		{
			Screen( Flat( from.x, from.y, foot ) ),
			Screen( Flat( to.x, to.y, foot ) ),
			Screen( Flat( to.x, to.y, head ) ),
			Screen( Flat( from.x, from.y, head ) )
		};
	}

	// What the step spans on the page in an elevation - the corner it starts at and the one it ends at, read off its
	// own travel so a turned segment is drawn over the ground it actually covers.
	static (Vector2 From, Vector2 To) Reach( ArchStairLane lane )
	{
		var frame = lane.Frame;

		return (frame.Flat( 0f, lane.Width * 0.5f ), frame.Flat( lane.Length, lane.Width * 0.5f ));
	}

	static Color Tint( ArchStairLane lane, bool picked )
	{
		return Hue( lane ).WithAlpha( picked ? 0.55f : 0.30f );
	}

	static Color Hue( ArchStairLane lane ) => lane.Climbs ? ArchStairInk.Flight : ArchStairInk.Platform;

	// The treads, numbered the way a drawn stair is - so a flight that came out with nineteen risers where twelve
	// were wanted says so on the page rather than needing to be counted in the viewport.
	void PaintTreads( ArchStairPart stair, ArchStairLane lane, int index, float foot, float head )
	{
		var going = stair.StepGoing > 0.5f ? stair.StepGoing : 12f;
		var riser = stair.StepRise > 0.5f ? stair.StepRise : 8f;
		var steps = ArchStairLanes.Steps( head - foot, riser, lane.Length, going );

		Paint.ClearBrush();
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.22f ), 1f );

		for ( var step = 1; step < steps; step++ )
		{
			var part = step / (float)steps;

			var (from, to) = Nosing( lane, part, foot, head );

			Paint.DrawLine( Screen( from ), Screen( to ) );
		}
	}

	// One nosing line, in whichever pane is open: across the run in plan, and up the rake in an elevation. Measured
	// out of the step's own frame, so which of the four numbers it lies on never has to be worked out.
	(Vector2 From, Vector2 To) Nosing( ArchStairLane lane, float part, float foot, float head )
	{
		var lift = View == ArchStairView.Plan ? 0f : foot + (head - foot) * part;
		var frame = lane.Frame;
		var along = lane.Length * part;

		var from = frame.Flat( along, 0f );
		var to = frame.Flat( along, lane.Width );

		return (Flat( from.x, from.y, lift ), Flat( to.x, to.y, lift ));
	}

	void PaintLabel( Rect body, ArchStairLane lane, int index, float climb )
	{
		if ( body.Width < 34f || body.Height < 16f )
		{
			return;
		}

		Paint.SetPen( Theme.TextControl.WithAlpha( 0.85f ) );
		Paint.SetDefaultFont( 8 );

		var caption = lane.Climbs
			? $"{index + 1}  {lane.Length:0} run · {climb:0} climb"
			: $"{index + 1}  platform {lane.Length:0} deep";

		Paint.DrawText( body, caption, TextFlag.Center );
	}

	// ---- The climb, drawn as a route ----

	// An arrow up each step and a connector between them, so the ORDER of the climb and every joint in it read at a
	// glance: which way each step is walked, which step follows which, and - in red - a joint that is not closed.
	// A drawing of rectangles cannot say any of that, which is why a chain that had come apart looked fine.
	void PaintFlow( ArchStairPart stair, float[] climbs, float[] bases )
	{
		if ( View != ArchStairView.Plan )
		{
			return;
		}

		for ( var index = 0; index < stair.Lanes.Count; index++ )
		{
			var lane = stair.Lanes[index];
			var frame = lane.Frame;
			var foot = Screen( Flat( frame.Flat( 0f, lane.Width * 0.5f ).x, frame.Flat( 0f, lane.Width * 0.5f ).y, 0f ) );
			var head = Screen( Flat( frame.Flat( lane.Length, lane.Width * 0.5f ).x, frame.Flat( lane.Length, lane.Width * 0.5f ).y, 0f ) );

			Paint.ClearBrush();
			Paint.SetPen( ArchStairInk.Flow.WithAlpha( index == Picked ? 0.9f : 0.4f ), index == Picked ? 2f : 1.5f );
			Paint.DrawLine( foot, head );

			Arrow( foot, head, index == Picked ? 9f : 7f );

			if ( index + 1 >= stair.Lanes.Count )
			{
				continue;
			}

			var next = stair.Lanes[index + 1];
			var stands = next.Frame.Flat( 0f, next.Width * 0.5f );
			var onto = Screen( Flat( stands.x, stands.y, 0f ) );

			// From the JOINT, not from the head: a step joined to a flank leaves from that flank, and an arrow drawn
			// off the head would point at a corner nothing happens at.
			var meets = Nearest( lane, next, stands );
			var joint = Screen( Flat( meets.x, meets.y, 0f ) );
			var apart = (joint - onto).Length > GripSize;

			Paint.SetPen( apart ? ArchStairInk.Floors : ArchStairInk.Flow.WithAlpha( 0.35f ), apart ? 2f : 1f );
			Paint.DrawLine( joint, onto );

			if ( apart )
			{
				Arrow( joint, onto, 7f );
			}
		}
	}

	// Which station on a step the one above it actually stands at - the joint, read back from where its foot is
	// rather than assumed to be the head.
	static Vector2 Nearest( ArchStairLane below, ArchStairLane next, Vector2 foot )
	{
		return ArchStairSteps.Stations( below, next.Width )
			.OrderBy( station => (station.At - foot).Length )
			.Select( station => station.At )
			.FirstOrDefault();
	}

	// One chevron at the far end of a line, drawn as two strokes rather than a filled head - it reads at any zoom
	// and never fills a short step with a triangle.
	static void Arrow( Vector2 from, Vector2 to, float size )
	{
		var span = to - from;

		if ( span.Length < size )
		{
			return;
		}

		var along = span.Normal;
		var across = new Vector2( -along.y, along.x );

		Paint.DrawLine( to, to - along * size + across * size * 0.5f );
		Paint.DrawLine( to, to - along * size - across * size * 0.5f );
	}

	// The step below lit up while a join waits, with a BOX on every station it offers - the joint is chosen from
	// what will actually fit rather than pointed at freehand, and a broad side offering two boxes is a switchback
	// you can see before you draw it.
	void PaintJoining( ArchStairPart stair )
	{
		if ( joining < 1 || joining >= stair.Lanes.Count )
		{
			return;
		}

		var ring = stair.Lanes[joining - 1].Corners()
			.Select( corner => Screen( Flat( corner.x, corner.y, 0f ) ) )
			.ToArray();

		Paint.ClearPen();
		Paint.SetBrush( ArchStairInk.Flow.WithAlpha( 0.15f ) );
		Paint.DrawPolygon( ring );

		Paint.ClearBrush();
		Paint.SetPen( ArchStairInk.Flow, 2f );

		for ( var corner = 0; corner < ring.Length; corner++ )
		{
			Paint.DrawLine( ring[corner], ring[(corner + 1) % ring.Length] );
		}

		foreach ( var station in Stations( stair ) )
		{
			var hot = Box( station.At ).Grow( StationReach ).IsInside( hover );

			Paint.ClearPen();
			Paint.SetBrush( ArchStairInk.Flow.WithAlpha( hot ? 1f : 0.7f ) );
			Paint.DrawRect( Box( station.At ).Grow( hot ? 3f : 1f ), 2 );
		}

		Paint.SetDefaultFont( 8 );
		Paint.SetPen( ArchStairInk.Flow );
		Paint.DrawText( new Rect( hover.x + 12f, hover.y - 8f, 260f, 16f ),
			$"click a box to join step {joining + 1} there", TextFlag.LeftCenter );
	}

	// The stations the step below offers, in the drawing's own coordinates - painted and hit-tested from one list,
	// so a box you can see is a box you can press.
	IEnumerable<(ArchStairStation Station, Vector2 At)> Stations( ArchStairPart stair )
	{
		var lane = stair.Lanes[joining];

		return ArchStairSteps.Stations( stair.Lanes[joining - 1], lane.Width )
			.Select( station => (station, Screen( Flat( station.At.x, station.At.y, 0f ) )) );
	}

	// ---- The grips ----

	// Only the PICKED step offers its widgets, exactly as the viewport does. Six steps' worth of squares standing at
	// once is a thicket you cannot aim into, and the step you clicked is the one you meant to work on.
	void Offer( ArchStairPart stair, int index, float[] climbs, float[] bases )
	{
		if ( index != Picked )
		{
			return;
		}

		var lane = stair.Lanes[index];
		var frame = lane.Frame;

		if ( View == ArchStairView.Plan )
		{
			grips.Add( (ArchStairGrip.Head, index, -1, frame.Flat( lane.Length, lane.Width * 0.5f )) );
			grips.Add( (ArchStairGrip.Left, index, -1, frame.Flat( lane.Length * 0.5f, lane.Width )) );
			grips.Add( (ArchStairGrip.Right, index, -1, frame.Flat( lane.Length * 0.5f, 0f )) );

			// EVERY step drags, and the ones around it give way rather than being passed through - so a stair is
			// moved by taking hold of it, and the first step carries the whole climb with it.
			grips.Add( (ArchStairGrip.Body, index, -1, frame.Flat( lane.Length * 0.5f, lane.Width * 0.5f )) );

			// Standing off the head, because it is the angle the step LEAVES at - dragging it round is how a
			// segment of a curve is aimed once the sweep has been cut.
			if ( lane.Climbs )
			{
				grips.Add( (ArchStairGrip.Bend, index, -1, frame.Flat( lane.Length + Elbow, lane.Width * 0.5f )) );
			}

			return;
		}

		var top = bases[index] - stair.BaseHeight + (lane.Climbs ? climbs[index] : 0f);
		var middle = frame.Flat( lane.Length * 0.5f, lane.Width * 0.5f );

		grips.Add( (ArchStairGrip.Lift, index, -1, Flat( middle.x, middle.y, top )) );
	}

	// A railing drawn along the edge it guards, over the stretch it actually covers. Its two ends and its body are
	// grips of their own, because a railing is a thing you put somewhere - not a property of the step under it.
	void PaintRails( ArchStairLane lane, int index )
	{
		if ( View != ArchStairView.Plan )
		{
			return;
		}

		for ( var guard = 0; guard < lane.Guards.Count; guard++ )
		{
			var rail = lane.Guards[guard];
			var (from, to) = RailLine( lane, rail );
			var picked = index == Picked && guard == PickedGuard;

			Paint.ClearBrush();
			Paint.SetPen( rail.Railing == StairRailing.Handrail
				? ArchStairInk.Handrail
				: ArchStairInk.Balustrade, picked ? 4f : 2.5f );

			Paint.DrawLine( Screen( Flat( from.x, from.y, 0f ) ), Screen( Flat( to.x, to.y, 0f ) ) );

			grips.Add( (ArchStairGrip.RailFoot, index, guard, from) );
			grips.Add( (ArchStairGrip.RailHead, index, guard, to) );
			grips.Add( (ArchStairGrip.Rail, index, guard, (from + to) * 0.5f) );
		}
	}

	// A railing lies along one of the step's own edges, over the stretch of its run that it covers - which is the
	// step's frame read at an across of nothing, its width, or the head it arrives at.
	static (Vector2 From, Vector2 To) RailLine( ArchStairLane lane, ArchStairGuardPart rail )
	{
		var frame = lane.Frame;

		if ( rail.Edge == StairEdge.Head )
		{
			return (frame.Flat( lane.Length, 0f ), frame.Flat( lane.Length, lane.Width ));
		}

		var hand = rail.Edge == StairEdge.Left ? lane.Width : 0f;

		return (frame.Flat( lane.Length * rail.Start, hand ), frame.Flat( lane.Length * rail.End, hand ));
	}

	// A dragged point read back as a fraction of the step's own run, which is what a railing's span IS.
	static float Fraction( ArchStairLane lane, Vector2 local )
	{
		return Math.Clamp( ArchStairEdges.Along( lane, local ) / MathF.Max( 1f, lane.Length ), 0f, 1f );
	}

	void PaintGrips()
	{
		foreach ( var grip in grips )
		{
			var at = Screen( View == ArchStairView.Plan
				? Flat( grip.At.x, grip.At.y, 0f )
				: grip.At );

			var hot = grip.Step == Picked;

			var hue = grip.Kind switch
			{
				ArchStairGrip.Head => ArchStairInk.Head,
				ArchStairGrip.Lift => ArchStairInk.Lift,
				ArchStairGrip.Body => ArchStairInk.Platform,
				ArchStairGrip.Bend => ArchStairInk.Bend,
				ArchStairGrip.Rail or ArchStairGrip.RailFoot or ArchStairGrip.RailHead => ArchStairInk.Balustrade,
				_ => ArchStairInk.Reach
			};

			Paint.ClearPen();
			Paint.SetBrush( hue.WithAlpha( hot ? 1f : 0.65f ) );

			Paint.DrawRect( Box( at ), 2 );
		}
	}

	static Rect Box( Vector2 at ) => new( at.x - GripSize * 0.5f, at.y - GripSize * 0.5f, GripSize, GripSize );

	static readonly (Color Hue, string Name)[] Keys =
	{
		(ArchStairInk.Flight, "flight"),
		(ArchStairInk.Platform, "platform"),
		(ArchStairInk.Balustrade, "railing"),
		(ArchStairInk.Walls, "walls"),
		(ArchStairInk.Floors, "floors")
	};

	// What the colours mean, on the page rather than in a manual - five swatches is cheaper than a reader working out
	// whether orange is the stair or the room it stands in.
	void PaintLegend()
	{
		var top = 8f;

		Paint.SetDefaultFont( 7 );

		foreach ( var key in Keys )
		{
			Paint.ClearPen();
			Paint.SetBrush( key.Hue.WithAlpha( 0.85f ) );
			Paint.DrawRect( new Rect( Width - 62f, top + 3f, 8f, 8f ), 2 );

			Paint.SetPen( Theme.TextControl.WithAlpha( 0.5f ) );
			Paint.DrawText( new Rect( Width - 50f, top, 44f, 14f ), key.Name, TextFlag.LeftCenter );

			top += 13f;
		}
	}

	// ---- The profile ----

	// The climb UNROLLED - distance walked across, height up. A stair that turns has no single elevation, because its
	// flights stand behind each other, so the profile is WALKED rather than looked at: it answers where a dog-leg
	// arrives exactly as it answers a straight run, and it is where the floors the climb has to meet are read.
	void PaintProfile( ArchStairPart stair, Rect band, float[] climbs, float[] bases )
	{
		Paint.ClearPen();
		Paint.SetBrush( Theme.ControlBackground.Darken( 0.45f ) );
		Paint.DrawRect( band );

		Paint.ClearBrush();
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.14f ), 1f );
		Paint.DrawLine( new Vector2( band.Left, band.Top ), new Vector2( band.Right, band.Top ) );

		var run = 0f;

		foreach ( var lane in stair.Lanes )
		{
			run += lane.Length;
		}

		var climb = MathF.Max( 1f, stair.TotalRise );
		var left = band.Left + 58f;
		var seat = band.Bottom - 20f;

		bandZoom = MathF.Min( (band.Right - 18f - left) / MathF.Max( 1f, run ), (seat - band.Top - 22f) / climb );
		bandFoot = new Vector2( left, seat );

		PaintFloorLines( stair, band );
		PaintClimbNote( stair, band, run, PaintWalked( stair, band, climbs, bases ) );
	}

	Vector2 Profiled( float along, float lift ) => new( bandFoot.x + along * bandZoom, bandFoot.y - lift * bandZoom );

	void PaintFloorLines( ArchStairPart stair, Rect band )
	{
		Paint.SetDefaultFont( 7 );

		foreach ( var (level, lift) in Floors( stair ) )
		{
			var line = Profiled( 0f, lift ).y;

			if ( line < band.Top + 12f || line > band.Bottom - 6f )
			{
				continue;
			}

			Paint.ClearBrush();
			Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.45f ), 1f );
			Paint.DrawLine( new Vector2( band.Left + 54f, line ), new Vector2( band.Right - 8f, line ) );

			Paint.SetPen( ArchStairInk.Floors.WithAlpha( 0.9f ) );
			Paint.DrawText( new Rect( band.Left + 6f, line - 7f, 44f, 14f ),
				$"{level} · {stair.BaseHeight + lift:0}", TextFlag.RightCenter );
		}
	}

	// Every step in the order it is walked, and where the last one arrives.
	float PaintWalked( ArchStairPart stair, Rect band, float[] climbs, float[] bases )
	{
		var along = 0f;
		var carried = Vector2.Zero;
		var arrival = 0f;

		for ( var index = 0; index < stair.Lanes.Count; index++ )
		{
			var lane = stair.Lanes[index];
			var foot = bases[index] - stair.BaseHeight;
			var head = foot + (lane.Climbs ? climbs[index] : 0f);
			var picked = index == Picked;
			var start = Profiled( along, foot );

			walked.Add( (index, new Rect( start.x, band.Top, MathF.Max( 2f, lane.Length * bandZoom ), band.Height )) );

			// A pinned platform lifts the climb before it stands, so the walking line jumps. Drawn, because a split
			// level nobody can see is a split level nobody meant.
			if ( index > 0 && MathF.Abs( start.y - carried.y ) > 1f )
			{
				Paint.ClearBrush();
				Paint.SetPen( ArchStairInk.Platform.WithAlpha( 0.8f ), 1.5f );
				Paint.DrawLine( carried, start );
			}

			if ( lane.Climbs )
			{
				PaintRake( stair, lane, along, foot, head, picked );
			}
			else
			{
				PaintPad( lane, along, head, picked );
			}

			along += lane.Length;
			arrival = head;
			carried = Profiled( along, head );

			pins.Add( (index, carried) );

			Paint.ClearPen();
			Paint.SetBrush( ArchStairInk.Lift.WithAlpha( picked ? 1f : 0.6f ) );
			Paint.DrawRect( Box( carried ), 2 );

			if ( !picked )
			{
				continue;
			}

			Paint.SetDefaultFont( 7 );
			Paint.SetPen( Theme.Primary );
			Paint.DrawText( new Rect( carried.x - 62f, carried.y - 16f, 60f, 13f ),
				$"{stair.BaseHeight + head:0}", TextFlag.RightCenter );
		}

		return arrival;
	}

	// The treads themselves, filled down to the foot of the drawing - a section through the flight rather than a
	// line over it, which is what makes a headroom or an arrival read at a glance.
	void PaintRake( ArchStairPart stair, ArchStairLane lane, float along, float foot, float head, bool picked )
	{
		var going = stair.StepGoing > 0.5f ? stair.StepGoing : 12f;
		var riser = stair.StepRise > 0.5f ? stair.StepRise : 8f;
		var steps = ArchStairLanes.Steps( head - foot, riser, lane.Length, going );
		var run = lane.Length / steps;
		var lift = (head - foot) / steps;

		Paint.ClearPen();
		Paint.SetBrush( ArchStairInk.Flight.WithAlpha( picked ? 0.5f : 0.24f ) );

		for ( var step = 0; step < steps; step++ )
		{
			var top = Profiled( along + run * step, foot + lift * (step + 1) );
			var toe = Profiled( along + run * (step + 1), 0f );

			Paint.DrawRect( Rect.FromPoints( top, new Vector2( toe.x, bandFoot.y ) ) );
		}

		Paint.ClearBrush();
		Paint.SetPen( picked ? Theme.Primary : ArchStairInk.Flight, picked ? 2.5f : 1.5f );
		Paint.DrawLine( Profiled( along, foot ), Profiled( along + lane.Length, head ) );
	}

	void PaintPad( ArchStairLane lane, float along, float level, bool picked )
	{
		var top = Profiled( along, level );
		var end = Profiled( along + lane.Length, level );

		Paint.ClearPen();
		Paint.SetBrush( ArchStairInk.Platform.WithAlpha( picked ? 0.5f : 0.24f ) );
		Paint.DrawRect( Rect.FromPoints( top, new Vector2( end.x, bandFoot.y ) ) );

		Paint.ClearBrush();
		Paint.SetPen( picked ? Theme.Primary : ArchStairInk.Platform, picked ? 2.5f : 1.5f );
		Paint.DrawLine( top, end );
	}

	// What the climb comes to: the height the last step arrives at, which is the number a stair is judged by and the
	// one no plan can show.
	void PaintClimbNote( ArchStairPart stair, Rect band, float run, float arrival )
	{
		Paint.SetDefaultFont( 7 );
		Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
		Paint.DrawText( new Rect( band.Left + 58f, band.Top + 3f, band.Width - 70f, 14f ),
			$"profile · {run:0} walked · {arrival:0} climb · arrives {stair.BaseHeight + arrival:0}", TextFlag.LeftCenter );

		var head = Profiled( run, arrival );

		// The drop to the foot only - the arrival is the last step's own head pin, and a second mark standing on it
		// is one mark you can grab and one you cannot.
		Paint.ClearBrush();
		Paint.SetPen( ArchStairInk.Lift.WithAlpha( 0.7f ), 1f );
		Paint.DrawLine( head, new Vector2( head.x, bandFoot.y ) );
	}

	// ---- Dragging ----

	// Where two steps meet is asked for, never guessed: name the step on its own menu, then point at the spot on the
	// step below where its foot should stand.
	protected override void OnContextMenu( ContextMenuEvent e )
	{
		if ( subject() is not { Core: not null } stair || StepUnder( stair, hover ) is var step && step < 0 )
		{
			return;
		}

		Picked = step;
		Selected?.Invoke( Picked );

		var menu = new ContextMenu( this );

		var join = menu.AddOption( "Join to the step below", "link", () =>
		{
			joining = step;
			Update();
		} );

		join.Enabled = step > 0;

		menu.OpenAtCursor();

		e.Accepted = true;
	}

	protected override void OnMousePress( MouseEvent e )
	{
		base.OnMousePress( e );

		held = -1;
		dragging = false;

		if ( subject() is not { Core: not null } stair )
		{
			return;
		}

		// A join waiting for its point takes the click outright - nothing else in the drawing is being asked for
		// until it has one.
		if ( joining >= 1 && joining < stair.Lanes.Count )
		{
			Joined( stair, e.LocalPosition );

			return;
		}

		// The head pins in the profile come first: they stand ON a step's own stride, and a pin you can see and
		// cannot grab is the drawing lying about what it offers.
		foreach ( var pin in pins )
		{
			if ( !Box( pin.At ).Grow( 4f ).IsInside( e.LocalPosition ) )
			{
				continue;
			}

			held = pin.Step;
			heldKind = ArchStairGrip.Lift;
			heldGuard = -1;
			holding = stair;
			dragging = true;
			profiling = true;
			Picked = pin.Step;
			Selected?.Invoke( Picked );
			Update();

			return;
		}

		// The profile is a drawing of the same steps, so a step is picked in it the way it is picked in the plan.
		foreach ( var stride in walked )
		{
			if ( !stride.At.IsInside( e.LocalPosition ) )
			{
				continue;
			}

			Picked = stride.Step;
			Selected?.Invoke( Picked );
			Update();

			return;
		}

		for ( var index = 0; index < grips.Count; index++ )
		{
			var grip = grips[index];
			var at = Screen( View == ArchStairView.Plan ? Flat( grip.At.x, grip.At.y, 0f ) : grip.At );

			if ( !Box( at ).Grow( 3f ).IsInside( e.LocalPosition ) )
			{
				continue;
			}

			held = grip.Step;
			heldKind = grip.Kind;
			heldGuard = grip.Guard;
			holding = stair;
			dragging = true;
			Picked = grip.Step;
			PickedGuard = grip.Guard;
			Selected?.Invoke( Picked );
			Update();

			return;
		}

		// A press that caught no grip still picks the step under it, so the list and the drawing agree about
		// which row is being worked on.
		if ( StepUnder( stair, e.LocalPosition ) is var step && step >= 0 )
		{
			Picked = step;
			Selected?.Invoke( Picked );
			Update();
		}
	}

	int StepUnder( ArchStairPart stair, Vector2 screen )
	{
		if ( View != ArchStairView.Plan )
		{
			return -1;
		}

		var local = Local( screen );

		for ( var index = 0; index < stair.Lanes.Count; index++ )
		{
			if ( ArchFootprint.Contains( stair.Lanes[index].Corners(), local ) )
			{
				return index;
			}
		}

		return -1;
	}

	// The box pressed IS the joint. A press that caught none leaves the join standing, so a misclick costs a click
	// rather than the gesture.
	void Joined( ArchStairPart stair, Vector2 screen )
	{
		var picked = Stations( stair ).FirstOrDefault( station => Box( station.At ).Grow( StationReach ).IsInside( screen ) );

		if ( picked.At == default )
		{
			Update();

			return;
		}

		var lane = stair.Lanes[joining];

		joining = -1;

		ArchStairSteps.JoinTo( stair, lane, picked.Station );
		ArchStairLanes.Reseat( stair.Core, stair.Lanes );

		committed?.Invoke();
	}

	protected override void OnMouseMove( MouseEvent e )
	{
		base.OnMouseMove( e );

		hover = e.LocalPosition;

		if ( !dragging || held < 0 || holding is not { Core: not null } stair || held >= stair.Lanes.Count )
		{
			if ( joining >= 0 )
			{
				Update();
			}

			return;
		}

		var lane = stair.Lanes[held];
		var local = Local( e.LocalPosition );

		if ( heldKind == ArchStairGrip.Lift )
		{
			var bases = ArchStairEdges.Bases( stair );
			var foot = bases[held] - stair.BaseHeight;
			var lift = profiling
				? (bandFoot.y - e.LocalPosition.y) / MathF.Max( 0.0001f, bandZoom )
				: -local.y;

			ArchStairSteps.Lift( stair, lane, ArchGridService.Fine( lift - foot ) );
		}
		else if ( heldKind is ArchStairGrip.Rail or ArchStairGrip.RailFoot or ArchStairGrip.RailHead )
		{
			if ( heldGuard < 0 || heldGuard >= lane.Guards.Count )
			{
				return;
			}

			var rail = lane.Guards[heldGuard];
			var at = Fraction( lane, local );

			if ( heldKind == ArchStairGrip.RailFoot )
			{
				rail.From = MathF.Min( at, rail.End - 0.02f );
			}
			else if ( heldKind == ArchStairGrip.RailHead )
			{
				rail.To = MathF.Max( at, rail.Start + 0.02f );
			}
			else
			{
				// The whole railing slid along its edge, keeping the stretch it covers.
				var reach = rail.End - rail.Start;
				var start = Math.Clamp( at - reach * 0.5f, 0f, 1f - reach );

				rail.From = start;
				rail.To = start + reach;
			}
		}
		else if ( heldKind == ArchStairGrip.Body )
		{
			ArchStairSteps.Move( stair, lane, Rested( stair, local ) );
		}
		else if ( heldKind == ArchStairGrip.Bend )
		{
			ArchStairSteps.Bend( stair, lane, Aimed( stair, lane, local ) );
		}
		else
		{
			var point = Rested( stair, local );

			if ( heldKind == ArchStairGrip.Head )
			{
				ArchStairEdges.Head( lane, point );
			}
			else
			{
				ArchStairEdges.Flank( lane, point, heldKind == ArchStairGrip.Left );
			}

			ArchStairSteps.Settle( stair, lane );
		}

		ArchStairLanes.Fit( stair.Core, stair.Lanes );

		changed?.Invoke();
		Update();
	}

	// Where a grip dragged in the plan comes to rest, in the shaft's own numbers: a wall standing on the stair's
	// storey takes it ahead of the grid, so a flight is put against plaster here exactly as it is in the viewport.
	// Off the plan there is no world point to snap - an elevation is two of the three axes and one of them is height.
	Vector2 Rested( ArchStairPart stair, Vector2 local )
	{
		if ( View == ArchStairView.Plan && tool is not null
			&& tool.Walled( stair.Core.Flat( local.x, local.y ), out var wall, Storey ) )
		{
			return ArchStairLanes.Local( stair.Core, wall );
		}

		return new Vector2( ArchGridService.Fine( local.x ), ArchGridService.Fine( local.y ) );
	}

	// The angle from the step's seat to the cursor, less the cardinal its walk already points along - so dragging the
	// bend grip aims the step where you are pointing rather than by however far the mouse moved. Snapped to a whole
	// angle step, because a curve of segments each standing at a fraction of a degree is a curve nobody can state -
	// and to the way a NEIGHBOUR already points when it comes within one of them, so a run turned to line up with
	// the flight it meets lines up with it exactly rather than missing by a degree.
	static float Aimed( ArchStairPart stair, ArchStairLane lane, Vector2 local )
	{
		var reach = local - lane.Seat;

		if ( reach.IsNearZeroLength )
		{
			return lane.Bearing;
		}

		var aimed = ArchGridService.Snap( MathF.Atan2( reach.y, reach.x ).RadianToDegree() - lane.Cardinal, AngleStep );
		var index = stair.Lanes.IndexOf( lane );

		foreach ( var neighbour in new[] { index - 1, index + 1 } )
		{
			if ( neighbour < 0 || neighbour >= stair.Lanes.Count
				|| MathF.Abs( aimed - stair.Lanes[neighbour].Bearing ) >= AngleStep )
			{
				continue;
			}

			return stair.Lanes[neighbour].Bearing;
		}

		return aimed;
	}

	// The commit waits for the release, or a drag stacks one undo entry per pixel.
	protected override void OnMouseReleased( MouseEvent e )
	{
		base.OnMouseReleased( e );

		if ( dragging )
		{
			// The shaft laid back onto the steps standing in it, once - during the drag it only ever grows, because
			// a box that re-seats under the cursor moves the very frame the drag is being read in.
			if ( holding is { Core: not null } stair )
			{
				ArchStairLanes.Reseat( stair.Core, stair.Lanes );
			}

			committed?.Invoke();
		}

		dragging = false;
		profiling = false;
		held = -1;
		holding = null;
	}
}