Editor/Stair/ArchStairLanes.cs

Editor utility for stair geometry. Computes climb totals and per-lane climbs, step counts, local coordinates, fits/reseats the core frame to lanes, numbers lanes, converts old leg data to lanes, and computes landings between flights.

File Access
namespace Sunless.Architecture;

// Everything the core knows about the steps standing in it: how the climb is shared, how many treads a flight
// takes, and how the shaft follows what was drawn in it.
//
// Landings are NOT among them any more. A landing is a step in the list, so the space between two flights is
// authored rather than worked out - which is what lets one be added, moved, resized and deleted where no turn
// happens to be. Settle is the one place the old derive survives, and it runs once per plan.
public static class ArchStairLanes
{
	const float Nothing = 0.05f;

	public const float MinLane = 18f;

	// ---- The climb ----

	// The whole climb the core actually stands. Pins RAISE the box rather than being clipped by it, because the
	// core follows its lanes - a flight told to climb 96 inches climbs 96 inches.
	public static float Climb( ArchStairCore core, IReadOnlyList<ArchStairLane> lanes )
	{
		var pinned = 0f;

		foreach ( var lane in lanes )
		{
			pinned += MathF.Max( 0f, lane.Rise );
		}

		return MathF.Max( MathF.Max( 1f, core?.Rise ?? 0f ), pinned );
	}

	// What each step climbs. A pinned one takes exactly what it was dragged to; the unpinned FLIGHTS share what
	// is left in proportion to how far they run, so a long flight takes more of the climb than a short one and
	// the risers across an unpinned stair come out even.
	//
	// An unpinned LANDING takes none, which is what makes it level. Pinning one is what turns it into a split
	// level, and it is the same field and the same lift arrow either way.
	public static float[] Climbs( ArchStairCore core, IReadOnlyList<ArchStairLane> lanes )
	{
		var climbs = new float[lanes.Count];

		if ( lanes.Count == 0 )
		{
			return climbs;
		}

		var pinned = 0f;
		var openSpan = 0f;
		var openCount = 0;

		foreach ( var lane in lanes )
		{
			if ( lane.Rise > Nothing )
			{
				pinned += lane.Rise;

				continue;
			}

			if ( !lane.Climbs )
			{
				continue;
			}

			openSpan += lane.Length;
			openCount++;
		}

		var free = MathF.Max( 0f, Climb( core, lanes ) - pinned );

		for ( var index = 0; index < lanes.Count; index++ )
		{
			var lane = lanes[index];

			if ( lane.Rise > Nothing )
			{
				climbs[index] = lane.Rise;

				continue;
			}

			if ( !lane.Climbs )
			{
				continue;
			}

			climbs[index] = openSpan > Nothing
				? free * lane.Length / openSpan
				: free / MathF.Max( 1, openCount );
		}

		return climbs;
	}

	// The steps a flight takes: enough for the RISER to stay near its target, and enough for the GOING to as
	// well - whichever needs more. Treads always tile the drawn run exactly, so the two are one question, and
	// taking only the climb would cut a long shallow flight into a handful of enormous treads while taking only
	// the run would put a trip at the head of every carve. Both targets are honoured or the deeper count wins,
	// which is also what lets a pinned three-riser flight stand beside an unpinned fourteen-riser one.
	public static int Steps( float climb, float riser, float length, float going )
	{
		var byRise = (int)MathF.Round( climb / MathF.Max( 1f, riser ) );
		var byRun = (int)MathF.Round( length / MathF.Max( 1f, going ) );

		return Math.Max( 1, Math.Max( byRise, Math.Min( byRun, (int)MathF.Floor( climb / ShallowestRiser ) ) ) );
	}

	// A flight that runs far longer than its climb can carry is a ramp, not a stair: tiling it by the going alone
	// cuts a tread every foot for a climb of an inch, which is thousands of faces nobody asked for and what made a
	// long flight freeze the tool. This is well under any riser anybody builds, so it only ever bites a flight
	// that is already not one - a real stair runs about one and a half times its climb, and this bites at six.
	public const float ShallowestRiser = 2f;

	public static Vector2 Local( ArchStairCore core, Vector2 point )
	{
		var axes = core.Axes;
		var offset = point - core.Origin;

		return new Vector2( Vector2.Dot( offset, axes.Along ), Vector2.Dot( offset, axes.Across ) );
	}

	// ---- The core follows its lanes ----

	// What the steps standing in the shaft actually reach, read off their CORNERS rather than their four numbers -
	// a swept segment's footprint is turned about its seat, so the numbers describe where it would have stood.
	static (float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo) Bounds( IReadOnlyList<ArchStairLane> lanes )
	{
		var alongFrom = float.MaxValue;
		var alongTo = float.MinValue;
		var acrossFrom = float.MaxValue;
		var acrossTo = float.MinValue;

		foreach ( var corner in lanes.SelectMany( lane => lane.Corners() ) )
		{
			alongFrom = MathF.Min( alongFrom, corner.x );
			alongTo = MathF.Max( alongTo, corner.x );
			acrossFrom = MathF.Min( acrossFrom, corner.y );
			acrossTo = MathF.Max( acrossTo, corner.y );
		}

		return (alongFrom, alongTo, acrossFrom, acrossTo);
	}

	// A lane carved past the box grows the box rather than being clipped by it: the core is a frame that fits what
	// is inside it, never a fence a flight has to be drawn around.
	public static bool Fit( ArchStairCore core, IReadOnlyList<ArchStairLane> lanes )
	{
		if ( core is null || lanes.Count == 0 )
		{
			return false;
		}

		var (alongFrom, alongTo, acrossFrom, acrossTo) = Bounds( lanes );

		alongFrom = MathF.Min( alongFrom, 0f );
		acrossFrom = MathF.Min( acrossFrom, 0f );
		alongTo = MathF.Max( alongTo, core.Length );
		acrossTo = MathF.Max( acrossTo, core.Width );

		if ( alongFrom > -Nothing && acrossFrom > -Nothing
			&& alongTo - core.Length < Nothing && acrossTo - core.Width < Nothing )
		{
			return false;
		}

		core.Origin = core.Flat( alongFrom, acrossFrom );
		core.Length = alongTo - alongFrom;
		core.Width = acrossTo - acrossFrom;

		foreach ( var lane in lanes )
		{
			lane.AlongFrom -= alongFrom;
			lane.AlongTo -= alongFrom;
			lane.AcrossFrom -= acrossFrom;
			lane.AcrossTo -= acrossFrom;
		}

		return true;
	}

	// The shaft laid back onto exactly the steps standing in it. Fit only ever GROWS, which is what a dragged edge
	// wants; a turn carries a whole tail somewhere else, and a box that only grows leaves the stair rattling round
	// in the shaft the chain needed before the corner went in.
	public static void Reseat( ArchStairCore core, IReadOnlyList<ArchStairLane> lanes )
	{
		if ( core is null || lanes.Count == 0 )
		{
			return;
		}

		var (alongFrom, alongTo, acrossFrom, acrossTo) = Bounds( lanes );

		core.Origin = core.Flat( alongFrom, acrossFrom );
		core.Length = MathF.Max( MinLane, alongTo - alongFrom );
		core.Width = MathF.Max( MinLane, acrossTo - acrossFrom );

		foreach ( var lane in lanes )
		{
			lane.AlongFrom -= alongFrom;
			lane.AlongTo -= alongFrom;
			lane.AcrossFrom -= acrossFrom;
			lane.AcrossTo -= acrossFrom;
		}
	}

	// Every step in the climb needs an id of its own, so a railing can name the one it guards and a layer row
	// survives being reordered. Allocated late rather than at authoring, because a stair read off disk was
	// written before ids existed.
	public static void Number( ArchPlan plan, ArchStairPart stair )
	{
		foreach ( var lane in stair.Lanes.Where( lane => lane.Id == 0 ) )
		{
			lane.Id = plan.AllocateId();
		}
	}

	// ---- What a stair drawn before landings were steps was ----

	// The old model: flights only, with the landing between two of them worked out from what the core had left.
	// Read ONCE by ArchPlan.Normalize, which then leaves real landing lanes behind, so nothing downstream ever
	// derives a pad again. A stair that already carries one is left alone.
	public static bool Settle( ArchPlan plan, ArchStairPart stair )
	{
		if ( stair.Core is not { } core || stair.Lanes.Count == 0 || stair.Lanes.Any( lane => !lane.Climbs ) )
		{
			return false;
		}

		var settled = new List<ArchStairLane>();

		for ( var index = 0; index < stair.Lanes.Count; index++ )
		{
			var lane = stair.Lanes[index];

			settled.Add( lane );

			if ( index + 1 >= stair.Lanes.Count || Between( core, lane, stair.Lanes[index + 1] ) is not { } pad )
			{
				continue;
			}

			settled.Add( new ArchStairLane
			{
				Id = plan.AllocateId(),
				Step = StairStep.Landing,
				AlongFrom = pad.AlongFrom,
				AlongTo = pad.AlongTo,
				AcrossFrom = pad.AcrossFrom,
				AcrossTo = pad.AcrossTo,
				Walk = lane.Walk
			} );
		}

		foreach ( var lane in settled )
		{
			lane.LandingDepth = 0f;
		}

		stair.Lanes.Clear();
		stair.Lanes.AddRange( settled );

		return true;
	}

	readonly struct ArchStairLanding
	{
		public float AlongFrom { get; init; }
		public float AlongTo { get; init; }
		public float AcrossFrom { get; init; }
		public float AcrossTo { get; init; }
	}

	// The square between two flights, exactly as it was worked out before it became a step you can hold. Where
	// their edges already bound a rectangle - a quarter turn's corner - that rectangle IS the landing. Where they
	// lie on one line, as two lanes of a switchback do, it grows in the direction the flight below was walking
	// until it meets the other flight or the wall of the core.
	static ArchStairLanding? Between( ArchStairCore core, ArchStairLane lane, ArchStairLane next )
	{
		var head = lane.HeadEdge();
		var foot = next.FootEdge();

		var alongFrom = Least( head.AlongFrom, head.AlongTo, foot.AlongFrom, foot.AlongTo );
		var alongTo = Most( head.AlongFrom, head.AlongTo, foot.AlongFrom, foot.AlongTo );
		var acrossFrom = Least( head.AcrossFrom, head.AcrossTo, foot.AcrossFrom, foot.AcrossTo );
		var acrossTo = Most( head.AcrossFrom, head.AcrossTo, foot.AcrossFrom, foot.AcrossTo );

		var heading = lane.Heading;
		var alongAxis = MathF.Abs( heading.x ) > 0.5f;

		if ( alongAxis && alongTo - alongFrom < Nothing )
		{
			var line = head.AlongFrom;
			var depth = Depth( lane, Reach( core, lane, next, line, heading.x, acrossFrom, acrossTo, true ) );

			if ( depth < Nothing )
			{
				return null;
			}

			alongFrom = heading.x > 0f ? line : line - depth;
			alongTo = heading.x > 0f ? line + depth : line;
		}
		else if ( !alongAxis && acrossTo - acrossFrom < Nothing )
		{
			var line = head.AcrossFrom;
			var depth = Depth( lane, Reach( core, lane, next, line, heading.y, alongFrom, alongTo, false ) );

			if ( depth < Nothing )
			{
				return null;
			}

			acrossFrom = heading.y > 0f ? line : line - depth;
			acrossTo = heading.y > 0f ? line + depth : line;
		}

		if ( alongTo - alongFrom < Nothing || acrossTo - acrossFrom < Nothing )
		{
			return null;
		}

		return new ArchStairLanding
		{
			AlongFrom = alongFrom,
			AlongTo = alongTo,
			AcrossFrom = acrossFrom,
			AcrossTo = acrossTo
		};
	}

	static float Depth( ArchStairLane lane, float reach )
	{
		return lane.LandingDepth > Nothing ? MathF.Min( lane.LandingDepth, reach ) : reach;
	}

	// ONLY the pair is asked: a lane elsewhere in the chain stands at another height, and a multi-storey
	// switchback re-uses the same two rectangles flight after flight.
	static float Reach( ArchStairCore core, ArchStairLane lane, ArchStairLane next, float line, float direction, float from, float to, bool alongAxis )
	{
		var limit = direction > 0f ? alongAxis ? core.Length : core.Width : 0f;

		foreach ( var candidate in new[] { lane, next } )
		{
			var crossFrom = alongAxis ? candidate.AcrossFrom : candidate.AlongFrom;
			var crossTo = alongAxis ? candidate.AcrossTo : candidate.AlongTo;

			if ( crossTo <= from + Nothing || crossFrom >= to - Nothing )
			{
				continue;
			}

			var near = alongAxis ? candidate.AlongFrom : candidate.AcrossFrom;
			var far = alongAxis ? candidate.AlongTo : candidate.AcrossTo;

			if ( direction > 0f && near >= line - Nothing )
			{
				limit = MathF.Min( limit, near );
			}

			if ( direction < 0f && far <= line + Nothing )
			{
				limit = MathF.Max( limit, far );
			}
		}

		return MathF.Max( 0f, MathF.Abs( limit - line ) );
	}

	// ---- What a drawn chain of boxes was ----

	// The oldest authoring model, read once on load: the bounds of every leg become the core and each leg becomes
	// the flight it always was. A chain drawn at an angle keeps the first leg's yaw, which is the frame it was
	// aimed on.
	public static (ArchStairCore Core, List<ArchStairLane> Lanes) FromLegs( IReadOnlyList<ArchStairLeg> legs )
	{
		var core = new ArchStairCore { Yaw = legs.Count > 0 ? legs[0].Yaw : 0f, Origin = legs.Count > 0 ? legs[0].Start : Vector2.Zero };
		var lanes = new List<ArchStairLane>( legs.Count );

		if ( legs.Count == 0 )
		{
			return (core, lanes);
		}

		var frame = core.Axes;
		var alongFrom = float.MaxValue;
		var alongTo = float.MinValue;
		var acrossFrom = float.MaxValue;
		var acrossTo = float.MinValue;
		var boxes = new List<(float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo, StairWalk Walk)>();

		foreach ( var leg in legs )
		{
			var width = leg.Width > 1f ? leg.Width : 48f;
			var corners = leg.Axes.Rect( 0f, leg.Length, 0f, width );
			var lowAlong = float.MaxValue;
			var highAlong = float.MinValue;
			var lowAcross = float.MaxValue;
			var highAcross = float.MinValue;

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

				lowAlong = MathF.Min( lowAlong, along );
				highAlong = MathF.Max( highAlong, along );
				lowAcross = MathF.Min( lowAcross, across );
				highAcross = MathF.Max( highAcross, across );
			}

			var travel = leg.Span.IsNearZeroLength ? frame.Along : leg.Span.Normal;
			var forward = Vector2.Dot( travel, frame.Along );
			var sideways = Vector2.Dot( travel, frame.Across );

			boxes.Add( (lowAlong, highAlong, lowAcross, highAcross, MathF.Abs( sideways ) > MathF.Abs( forward )
				? sideways >= 0f ? StairWalk.Left : StairWalk.Right
				: forward >= 0f ? StairWalk.Ahead : StairWalk.Back) );

			alongFrom = MathF.Min( alongFrom, lowAlong );
			alongTo = MathF.Max( alongTo, highAlong );
			acrossFrom = MathF.Min( acrossFrom, lowAcross );
			acrossTo = MathF.Max( acrossTo, highAcross );
		}

		core.Origin = frame.Flat( alongFrom, acrossFrom );
		core.Length = MathF.Max( 1f, alongTo - alongFrom );
		core.Width = MathF.Max( 1f, acrossTo - acrossFrom );

		foreach ( var box in boxes )
		{
			lanes.Add( new ArchStairLane
			{
				AlongFrom = box.AlongFrom - alongFrom,
				AlongTo = box.AlongTo - alongFrom,
				AcrossFrom = box.AcrossFrom - acrossFrom,
				AcrossTo = box.AcrossTo - acrossFrom,
				Walk = box.Walk
			} );
		}

		return (core, lanes);
	}

	static float Least( float a, float b, float c, float d ) => MathF.Min( MathF.Min( a, b ), MathF.Min( c, d ) );

	static float Most( float a, float b, float c, float d ) => MathF.Max( MathF.Max( a, b ), MathF.Max( c, d ) );
}