Editor/Stair/ArchStairShape.cs

Editor-side types that describe resolved stair geometry and placement. Defines ArchStairRun, ArchStairPad, ArchStairWell and the partial ArchStairShape with methods to resolve lanes, compute steps, runs, pads, well openings, spans, railings and floor indexing for stair generation and editor tools.

File Access
namespace Sunless.Architecture;

public sealed class ArchStairRun
{
	public int Index { get; init; }
	public ArchStairAxes Axes { get; init; }
	public int Steps { get; init; }
	public float Going { get; init; }
	public float Rise { get; init; }
	public float Width { get; init; }
	public float BaseHeight { get; init; }
	public StairTurn Turn { get; init; }
	public bool Last { get; init; }
	// The run as it was DRAWN, before the shell took it back - so a flight that stops against a wall says so
	// rather than merely coming out short of where its author left the handle.
	public float Drawn { get; init; }
	public float DrawnWidth { get; init; }
	// The floor the run climbs out of - what the well above it and the rail beside it key off.
	public int Level { get; init; }

	// Measured, not authored: a walled side is a rail bolted to plaster.
	public bool WalledRight { get; init; }
	public bool WalledLeft { get; init; }

	// What each side carries, resolved ONCE: the step's own railing rows where it has any, the stair's Guard and
	// WallRail settings where it has none. The generator asks the run, never the stair, or a railing added to one
	// flight would have to be added to every other one to stop them all changing with it.
	public bool GuardLeft { get; init; }
	public bool GuardRight { get; init; }
	public bool HandrailLeft { get; init; }
	public bool HandrailRight { get; init; }

	// What the guard on each open side actually is: the whole balustrade, or the rake alone on its newels.
	public StairRailing RailingLeft { get; init; }
	public StairRailing RailingRight { get; init; }

	// The stretch of each side a railing actually covers, as a fraction of the run - so a balustrade dragged to
	// half a flight comes out over half that flight rather than all of it.
	public Vector2 SpanLeft { get; init; } = new( 0f, 1f );
	public Vector2 SpanRight { get; init; } = new( 0f, 1f );

	public float Length => Steps * Going;

	public float TopHeight => BaseHeight + Steps * Rise;

	public float StepTop( int step ) => BaseHeight + (step + 1) * Rise;

	// The pitch line — the plane the strings are cut to, which is what a baluster lands on.
	public float Rake( float along ) => BaseHeight + MathX.Clamp( along / MathF.Max( 1f, Length ), 0f, 1f ) * (TopHeight - BaseHeight);

	// The tread line a hand runs up: a riser above the pitch line, and never past the head, where the last tread
	// IS the landing - which is what lets the rake rail and the landing rail meet as one unbroken run.
	public float Nosing( float along ) => MathF.Min( Rake( along ) + Rise, TopHeight );

	// How far up the rake something this tall can ride before its top breaks the floor above.
	public float Under( float clearance )
	{
		return Length * ArchClearance.Cross( BaseHeight + clearance, TopHeight + clearance, TopHeight ).At;
	}

	public List<Vector2> Loop() => Loop( 0f );

	public List<Vector2> Loop( float from ) => Axes.Rect( Math.Clamp( from, 0f, MathF.Max( 0f, Length - 1f ) ), Length, 0f, Width );
}

public sealed class ArchStairPad
{
	// Which step of the climb this pad IS, so a widget writes the landing it is standing on rather than hunting
	// for the run whose frame it happens to share. The synthesised arrival belongs to no step and carries -1.
	public int Index { get; init; } = -1;

	public ArchStairAxes Axes { get; init; }
	public float AlongFrom { get; set; }
	public float AlongTo { get; set; }
	public float AcrossFrom { get; set; }
	public float AcrossTo { get; set; }
	public float Height { get; init; }
	public StairTurn Turn { get; init; }
	public bool Arrival { get; init; }
	// The floor this pad stands ON - null when the landing floats mid-storey.
	public int? Level { get; init; }
	// The storey the landing belongs to - what a one-storey ghost asks.
	public int Storey { get; init; }
	public bool WalledFrom { get; set; }
	public bool WalledTo { get; set; }
	public bool WalledHead { get; set; }

	// Set only where the landing carries railing rows of its own. Null leaves the turn to decide, which is what
	// a platform with nothing authored on it has always done.
	public bool? RailedFrom { get; init; }
	public bool? RailedTo { get; init; }
	public bool? RailedHead { get; init; }

	// Which of the landing's own edges carry a balustrade. The run below arrives over AlongFrom so that
	// edge is never railed, and a TURN leaves sideways - through a flank rather than over the head - so
	// which of the other three are open swaps with the turn. Railing both flanks stood a balustrade
	// across the square where two flights connect, and left the outer corner of the turn open.
	public bool RailsFrom => RailedFrom ?? (!WalledFrom && Turn != StairTurn.Right);

	public bool RailsTo => RailedTo ?? (!WalledTo && Turn != StairTurn.Left);

	public bool RailsHead => RailedHead ?? (!WalledHead && Turn != StairTurn.None);

	public List<Vector2> Loop() => Axes.Rect( AlongFrom, AlongTo, AcrossFrom, AcrossTo );
}

// One floor's opening: the loops that level's slab loses, at that floor's height.
public sealed class ArchStairWell
{
	public int Level { get; init; }
	public float Height { get; init; }
	public ArchRoom Probe { get; init; }
	public List<List<Vector2>> Loops { get; } = new();
}

// Resolved once, for generator, stairwell and ghost — never re-derived per caller.
public sealed partial class ArchStairShape
{
	public List<ArchStairRun> Runs { get; } = new();
	public List<ArchStairPad> Pads { get; } = new();
	// The stairwell, per floor - every slab the climb reaches loses its own loops.
	public List<ArchStairWell> Levels { get; } = new();

	public float Width { get; init; }
	public float Going { get; init; }
	public float Rise { get; init; }
	public float BaseHeight { get; init; }
	public float TopHeight { get; private set; }
	public int TopLevel { get; private set; }

	// The room-facing across edge: guard, wall socket and cupboard door all key off it.
	public float OpenEdge { get; private set; }
	public float ClosedEdge { get; private set; }
	public bool GuardLeft { get; init; }
	public bool GuardRight { get; init; }

	// One floor-height answer for resolve, pierce and the placement widget.
	public static float StoreyHeight( ArchBuilding building, ArchKit kit )
	{
		return building?.StoreyHeight > 1f ? building.StoreyHeight : kit.WallHeight + kit.FloorThickness;
	}

	public static float FloorOf( ArchBuilding building, ArchKit kit, int level )
	{
		return level * StoreyHeight( building, kit ) + kit.GroundClearance;
	}

	public static int FloorIndex( ArchBuilding building, ArchKit kit, float height )
	{
		return Math.Clamp( (int)MathF.Round( (height - kit.GroundClearance) / MathF.Max( 1f, StoreyHeight( building, kit ) ) ), 0, 64 );
	}

	// A stair with no lane authored is still a stair: one flight filling the whole shaft, which is what a core
	// dragged and left alone should be. Nowhere else has to test for an empty list.
	public static IReadOnlyList<ArchStairLane> Standing( ArchStairPart stair, ArchStairCore core )
	{
		if ( stair.Lanes.Count > 0 )
		{
			return stair.Lanes;
		}

		return new List<ArchStairLane>
		{
			new()
			{
				AlongFrom = 0f,
				AlongTo = MathF.Max( 1f, core.Length ),
				AcrossFrom = 0f,
				AcrossTo = MathF.Max( 1f, core.Width ),
				Walk = StairWalk.Ahead
			}
		};
	}

	// storey = every room of the flight's building; a wall is a wall whoever owns it.
	public static ArchStairShape Resolve( ArchStairPart stair, ArchRoom room, ArchKit kit, IEnumerable<ArchRoom> storey = null, ArchBuilding building = null )
	{
		var walls = storey ?? building?.Rooms;
		var core = stair.Core ?? new ArchStairCore();
		var lanes = Standing( stair, core );

		var going = stair.StepGoing > 0.5f ? stair.StepGoing : kit.StepGoing;
		// The riser a flight aims for. A carved flight bends it to arrive exactly on its host's coping, so the
		// stair's own beats the kit's - reading the kit here is what would put a trip at the top of every carve.
		var riser = stair.StepRise > 0.5f ? stair.StepRise : kit.StepRise;

		var guardLeft = stair.Guard is StairGuard.Left or StairGuard.Both;
		var guardRight = stair.Guard is StairGuard.Right or StairGuard.Both;
		var openLeft = guardLeft || !guardRight;
		var kinds = ArchKinds.Load();

		// Every step's own share of the climb, asked once, here, so the generator, the ghost, the widgets and the
		// report cannot disagree. The landings are read straight off the list - nothing derives one.
		var climbs = ArchStairLanes.Climbs( core, lanes );
		var lead = lanes.FirstOrDefault( lane => lane.Climbs ) ?? lanes[0];

		var width = MathF.Max( 1f, lead.Width > 1f ? lead.Width : stair.Width > 1f ? stair.Width : kit.StairWidth );

		// Where each step actually stands, asked before anything is measured off a length: a step is one side of
		// the shell or the other and never both, so what crosses over is taken off here and the risers the report
		// carries are the risers the treads come out at. Housing is per storey, because a climb passes through one
		// building at a time.
		var housings = new ArchStairHousings( walls, kit, kinds );
		var fits = Fitting( core, lanes, climbs, housings, building, kit, stair.BaseHeight );
		var totalSteps = 0;

		for ( var index = 0; index < lanes.Count; index++ )
		{
			if ( lanes[index].Climbs )
			{
				totalSteps += ArchStairLanes.Steps( climbs[index], riser, fits[index].AlongTo - fits[index].AlongFrom, going );
			}
		}

		var shape = new ArchStairShape
		{
			Width = width,
			Going = going,
			// The reported riser. Each run carries its own, because a pinned flight climbs what it was
			// dragged to - this is what the whole stair averages out at, which is what a warning reads.
			Rise = ArchStairLanes.Climb( core, lanes ) / Math.Max( 1, totalSteps ),
			BaseHeight = stair.BaseHeight,
			GuardLeft = guardLeft,
			GuardRight = guardRight,
			OpenEdge = openLeft ? width : 0f,
			ClosedEdge = openLeft ? 0f : width
		};

		var startLevel = FloorIndex( building, kit, stair.BaseHeight );
		var baseProbe = Probe( room, walls, startLevel, room, kinds );
		var height = stair.BaseHeight;

		// A landing is stated in the core's frame and read in the frame of the flight BELOW it - where every pad
		// has always lived, so the rail's PadBetween, the carriage and the well find it without a second
		// convention. A stair that opens on a landing has no flight below, so it borrows the core's own.
		var frame = core.Axes;
		var arrived = (ArchStairRun)null;

		for ( var index = 0; index < lanes.Count; index++ )
		{
			var lane = lanes[index];
			var level = FloorIndex( building, kit, height );
			var probe = Probe( room, walls, level, baseProbe, kinds );

			if ( !lane.Climbs )
			{
				height += climbs[index];

				var stood = fits[index];

				var landing = new ArchStairPad
				{
					Index = index,
					RailedFrom = Railed( lane, StairEdge.Right ),
					RailedTo = Railed( lane, StairEdge.Left ),
					RailedHead = Railed( lane, StairEdge.Head ),
					Axes = frame,
					AlongFrom = stood.AlongFrom,
					AlongTo = stood.AlongTo,
					AcrossFrom = stood.AcrossFrom,
					AcrossTo = stood.AcrossTo,
					Height = height,
					Turn = arrived?.Turn ?? StairTurn.None,
					Arrival = NextFlight( lanes, index ) is null,
					Level = PadLevel( building, kit, height ),
					Storey = FloorIndex( building, kit, height )
				};

				Corner( landing, probe, kit, kinds );

				shape.Pads.Add( landing );

				continue;
			}

			var next = NextFlight( lanes, index );
			var last = next is null;
			var climb = climbs[index];
			var drawn = MathF.Max( 1f, lane.Length );
			var seated = fits[index];
			var length = seated.AlongTo - seated.AlongFrom;
			var steps = ArchStairLanes.Steps( climb, riser, length, going );
			var laneWidth = seated.AcrossTo - seated.AcrossFrom;
			// A flank the shell took back moves the flight off the wall rather than narrowing it in place, so the
			// run's own frame is seated on the edge that survived.
			var walked = lane.Axes( core );
			var axes = new ArchStairAxes { Origin = walked.Flat( 0f, seated.AcrossFrom ), Yaw = walked.Yaw };
			var walledRight = Walled( probe, kit, axes, length * 0.5f, 0f, -1f, kinds );
			var walledLeft = Walled( probe, kit, axes, length * 0.5f, laneWidth, 1f, kinds );

			var run = new ArchStairRun
			{
				Index = index,
				Axes = axes,
				Steps = steps,
				Drawn = drawn,
				DrawnWidth = MathF.Max( 1f, lane.Width ),
				// The riser holds near the kit's and the GOING bends to tile the drawn run, which is what lets a
				// flight pinned to three risers stand beside one taking fourteen without either coming out wrong.
				Going = length / steps,
				Rise = climb / steps,
				Width = laneWidth,
				BaseHeight = height,
				Turn = last ? StairTurn.None : TurnBetween( core, lane, next ),
				Last = last,
				Level = level,
				WalledRight = walledRight,
				WalledLeft = walledLeft,
				GuardLeft = Carries( lane, StairEdge.Left ) ?? (stair.AutoRailings && guardLeft),
				GuardRight = Carries( lane, StairEdge.Right ) ?? (stair.AutoRailings && guardRight),
				RailingLeft = Kind( lane, StairEdge.Left ) ?? stair.LeftRailing,
				RailingRight = Kind( lane, StairEdge.Right ) ?? stair.RightRailing,
				// A rail bolted to plaster needs the plaster - on an open side that same row stands a rake on newels.
				HandrailLeft = walledLeft && (Carries( lane, StairEdge.Left, StairRailing.Handrail ) ?? (stair.AutoRailings && stair.WallRail)),
				HandrailRight = walledRight && (Carries( lane, StairEdge.Right, StairRailing.Handrail ) ?? (stair.AutoRailings && stair.WallRail)),
				SpanLeft = Spanned( lane, StairEdge.Left ),
				SpanRight = Spanned( lane, StairEdge.Right )
			};

			shape.Runs.Add( run );

			height = run.TopHeight;
			frame = axes;
			arrived = run;

			// The arrival is only synthesised where nothing was authored to stand on: a walk that ended on a
			// platform already has its landing, and standing a second one on top of it is a doubled slab.
			if ( last && stair.TopLanding && lanes[^1].Climbs )
			{
				var arrival = housings.On( FloorIndex( building, kit, height ) )
					.Fit( axes, length, length + MathF.Max( going, stair.TopLandingDepth ), 0f, laneWidth );

				shape.Pads.Add( new ArchStairPad
				{
					Axes = axes,
					AlongFrom = arrival.AlongFrom,
					AlongTo = arrival.AlongTo,
					AcrossFrom = arrival.AcrossFrom,
					AcrossTo = arrival.AcrossTo,
					Height = height,
					Turn = StairTurn.None,
					Arrival = true,
					Level = PadLevel( building, kit, height ),
					Storey = FloorIndex( building, kit, height )
				} );
			}
		}

		shape.TopHeight = height;
		shape.TopLevel = FloorIndex( building, kit, height );

		// An actual wall beats the guard's opinion on which side is open.
		if ( shape.Runs.Count > 0 && shape.Runs[0].WalledLeft != shape.Runs[0].WalledRight )
		{
			var walledLeft = shape.Runs[0].WalledLeft;

			shape.ClosedEdge = walledLeft ? width : 0f;
			shape.OpenEdge = walledLeft ? 0f : width;
		}

		// The stairwell: every slab the climb reaches, from the storey above the foot to the head.
		for ( var level = startLevel + 1; level <= shape.TopLevel; level++ )
		{
			var well = new ArchStairWell
			{
				Level = level,
				Height = FloorOf( building, kit, level ),
				Probe = Probe( room, walls, level, baseProbe, kinds )
			};

			var soffit = well.Height - kit.FloorThickness;
			var headroom = MathF.Max( 1f, kit.StairHeadroom );

			foreach ( var run in shape.Runs )
			{
				if ( run.BaseHeight + 0.5f >= well.Height )
				{
					continue;
				}

				// The slab opens only where a climber's head would meet THIS one. A multi-storey flight
				// has runs whole storeys under it that pass nowhere near, and taking the whole footprint
				// out for every one of them opened - and railed - the entire plate.
				var reach = ArchClearance.Cross( run.BaseHeight + headroom, run.TopHeight + headroom, soffit );

				if ( reach.Clears )
				{
					continue;
				}

				well.Loops.Add( run.Loop( reach.Blocked ? 0f : Riser( run, reach.At * run.Length ) ) );
			}

			foreach ( var pad in shape.Pads )
			{
				if ( PadWell( building, kit, pad ) == level && pad.Height + headroom > soffit )
				{
					well.Loops.Add( pad.Loop() );
				}
			}

			if ( well.Loops.Count > 0 )
			{
				shape.Levels.Add( well );
			}
		}

		return shape;
	}

	// Whether this step says anything about that edge itself. A step carrying railing rows states exactly what
	// stands on each of its edges; one carrying none says nothing at all, and the stair's own settings answer - so
	// adding a railing to one flight never silently restates the flights around it.
	static bool? Carries( ArchStairLane lane, StairEdge edge, StairRailing railing )
	{
		if ( lane.Guards.Count == 0 )
		{
			return null;
		}

		return lane.Guards.Any( guard => guard.Edge == edge && guard.Railing == railing );
	}

	// Whether anything at all was put on that edge, whichever kind it is - a rake on newels is still a guard.
	static bool? Carries( ArchStairLane lane, StairEdge edge )
	{
		if ( lane.Guards.Count == 0 )
		{
			return null;
		}

		return lane.Guards.Any( guard => guard.Edge == edge );
	}

	// Which of the two a placed railing asked for. A step carrying both on one edge is carrying a balustrade.
	static StairRailing? Kind( ArchStairLane lane, StairEdge edge )
	{
		if ( Carries( lane, edge ) != true )
		{
			return null;
		}

		return lane.Guards.Any( guard => guard.Edge == edge && guard.Railing == StairRailing.Balustrade )
			? StairRailing.Balustrade
			: StairRailing.Handrail;
	}

	// How much of that edge the railings on it actually reach over. Nothing authored is the whole edge, which is
	// what a derived railing has always been.
	static Vector2 Spanned( ArchStairLane lane, StairEdge edge )
	{
		var start = 1f;
		var end = 0f;
		var found = false;

		foreach ( var guard in lane.Guards )
		{
			if ( guard.Edge != edge || !guard.Stands )
			{
				continue;
			}

			start = MathF.Min( start, guard.Start );
			end = MathF.Max( end, guard.End );
			found = true;
		}

		return found ? new Vector2( start, end ) : new Vector2( 0f, 1f );
	}

	static bool? Railed( ArchStairLane lane, StairEdge edge )
	{
		return lane.Guards.Count == 0 ? null : lane.Guarding( edge );
	}

	// The next step in the climb that actually climbs. A landing between two flights is not a turn and takes no
	// share, so the flight below has to look past it to know which way it is about to go.
	static ArchStairLane NextFlight( IReadOnlyList<ArchStairLane> lanes, int after )
	{
		for ( var index = after + 1; index < lanes.Count; index++ )
		{
			if ( lanes[index].Climbs )
			{
				return lanes[index];
			}
		}

		return null;
	}

	// A landing is stated in the core's frame and read in the run's, which are the same axes turned by a right
	// angle - so the rectangle stays a rectangle and its corners only have to be re-measured.
	static void Framed( ArchStairAxes frame, ArchStairCore core, ArchStairLane landing, out float alongFrom, out float alongTo, out float acrossFrom, out float acrossTo )
	{
		alongFrom = float.MaxValue;
		alongTo = float.MinValue;
		acrossFrom = float.MaxValue;
		acrossTo = float.MinValue;

		foreach ( var corner in landing.Loop( core ) )
		{
			var local = corner - frame.Origin;
			var along = Vector2.Dot( local, frame.Along );
			var across = Vector2.Dot( local, frame.Across );

			alongFrom = MathF.Min( alongFrom, along );
			alongTo = MathF.Max( alongTo, along );
			acrossFrom = MathF.Min( acrossFrom, across );
			acrossTo = MathF.Max( acrossTo, across );
		}
	}

	// The turn is read off the two walks, so a stair never lies about which way it goes round.
	static StairTurn TurnBetween( ArchStairCore core, ArchStairLane here, ArchStairLane next )
	{
		return TurnKind( AngleDelta( next.Yaw( core ) - here.Yaw( core ) ) );
	}
}