Editor/Stair/ArchStairWalk.cs

Editor-side utility that converts a sequence of click points into a stair geometry. It computes lane segments, turns, landings and the stair core (origin, yaw, length, width, rise) and provides helpers to extend or inspect existing stair parts.

File Access
namespace Sunless.Architecture;

// What a walk came to. One resolve, so the ghost following the cursor and the stair the last click builds are the
// same steps in the same shaft.
public sealed class ArchStairWalked
{
	public ArchStairCore Core { get; init; }
	public List<ArchStairLane> Lanes { get; init; } = new();

	public bool Stands => Core is not null && Lanes.Count > 0;
}

// A stair is WALKED, the way a pipe run is routed: click the foot, then click each point it turns or arrives at.
// Every leg is a flight and every turn drops a platform in between them, so the PATTERN IS THE CLICKS - two points
// make a straight run, a corner makes a quarter turn, a leg doubling back makes a switchback - and there is no
// pattern to pick off a grid.
//
// Every leg after the first is read in the FIRST leg's frame and reduced to the nearest of its four directions,
// because a step is a rectangle in the shaft's frame and a turn is always a rectangle. That is the whole cost of
// the shaft model, and what it buys is that two steps either abut or they do not.
public static class ArchStairWalk
{
	readonly record struct Box( float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo );

	const float RightAngle = 90f;

	public static ArchStairWalked Sketch( IReadOnlyList<Vector3> points, float width, float climb )
	{
		if ( points is not { Count: >= 2 } || !Aim( points, out var yaw ) )
		{
			return new ArchStairWalked();
		}

		var frame = new ArchStairAxes { Origin = Flat( points[0] ), Yaw = yaw };
		var lanes = Steps( Local( frame, points ), Half( width ) );

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

		var core = new ArchStairCore { Yaw = yaw, Rise = MathF.Max( 1f, Climbed( points, climb ) ) };

		Seat( core, frame, lanes, Extents( lanes ) );

		return new ArchStairWalked { Core = core, Lanes = lanes };
	}

	// Where a walk may pick a standing stair up again, the way a pipe route clicks a node another run already
	// owns: the head of the last step, at the height the climb arrives at.
	public static bool Head( ArchStairPart stair, out Vector3 head )
	{
		head = default;

		if ( stair?.Core is not { } core || stair.Lanes.Count == 0 )
		{
			return false;
		}

		var lane = stair.Lanes[^1];
		var flat = lane.Axes( core ).Flat( lane.Length, lane.Width * 0.5f );

		head = new Vector3( flat.x, flat.y, stair.BaseHeight + stair.TotalRise );

		return true;
	}

	// Walking a stair that already stands: the new legs are read in ITS frame, so a flight added later turns
	// against the one below it exactly as it would have during the first walk.
	public static bool Extend( ArchStairPart stair, IReadOnlyList<Vector3> points, float width )
	{
		if ( stair?.Core is not { } core || points is not { Count: >= 2 } )
		{
			return false;
		}

		var frame = core.Axes;
		var walked = Steps( Local( frame, points ), Half( width ) );

		if ( walked.Count == 0 )
		{
			return false;
		}

		var standing = stair.Lanes.ToList();
		var bounds = Extents( standing.Append( Whole( core ) ).Concat( walked ) );

		// Only the JOIN is turned. The standing steps were retreated when they were walked, and retreating them
		// a second time is a stair that shrinks every time another flight is added to it.
		if ( standing.Count > 0 && Turn( standing[^1], walked[0] ) is { } landing )
		{
			standing.Add( landing );
		}

		standing.AddRange( walked );

		Seat( core, frame, standing, bounds );

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

		core.Rise = MathF.Max( core.Rise, points[^1].z - stair.BaseHeight );

		return true;
	}

	// ---- The legs ----

	static List<ArchStairLane> Steps( IReadOnlyList<Vector2> locals, float half )
	{
		var flights = Banded( locals, half );
		var steps = new List<ArchStairLane>();

		for ( var index = 0; index < flights.Count; index++ )
		{
			steps.Add( flights[index] );

			if ( index + 1 < flights.Count && Turn( flights[index], flights[index + 1] ) is { } landing )
			{
				steps.Add( landing );
			}
		}

		return steps;
	}

	static List<ArchStairLane> Banded( IReadOnlyList<Vector2> locals, float half )
	{
		var lanes = new List<ArchStairLane>();

		for ( var index = 0; index + 1 < locals.Count; index++ )
		{
			if ( Leg( locals[index], locals[index + 1], half ) is { } lane )
			{
				lanes.Add( lane );
			}
		}

		return lanes;
	}

	// The band is centred on the leg's HEAD, because that is the click the author aimed: a flight doubling back
	// is drawn by clicking where its bottom step lands, and centring on the foot would put it back on the flight
	// it came off rather than beside it.
	static ArchStairLane Leg( Vector2 from, Vector2 to, float half )
	{
		var span = to - from;

		if ( MathF.Abs( span.x ) < ArchStairLanes.MinLane && MathF.Abs( span.y ) < ArchStairLanes.MinLane )
		{
			return null;
		}

		if ( MathF.Abs( span.y ) > MathF.Abs( span.x ) )
		{
			return new ArchStairLane
			{
				AlongFrom = to.x - half,
				AlongTo = to.x + half,
				AcrossFrom = MathF.Min( from.y, to.y ),
				AcrossTo = MathF.Max( from.y, to.y ),
				Walk = span.y >= 0f ? StairWalk.Left : StairWalk.Right
			};
		}

		return new ArchStairLane
		{
			AlongFrom = MathF.Min( from.x, to.x ),
			AlongTo = MathF.Max( from.x, to.x ),
			AcrossFrom = to.y - half,
			AcrossTo = to.y + half,
			Walk = span.x >= 0f ? StairWalk.Ahead : StairWalk.Back
		};
	}

	// Two flights that turn give the corner up rather than running into each other, and the platform IS what they
	// gave up. A quarter turn gives half the other's width apiece, which leaves the corner square; a flight
	// doubling back gives a whole width, because two steps on one axis share no corner and the half-landing has
	// to come out of the run. Two flights walking the same way are a continuation and give up nothing.
	static ArchStairLane Turn( ArchStairLane lane, ArchStairLane next )
	{
		if ( lane.Walk == next.Walk )
		{
			return null;
		}

		var room = MathF.Min( lane.Length, next.Length ) * 0.4f;
		var back = next.Walk == lane.Reversed;

		var head = MathF.Max( 0f, MathF.Min( back ? MathF.Min( lane.Width, next.Width ) : next.Width * 0.5f, room ) );
		var foot = MathF.Max( 0f, MathF.Min( back ? head : lane.Width * 0.5f, room ) );

		if ( head < 0.05f && foot < 0.05f )
		{
			return null;
		}

		var before = Rect( lane );
		var after = Rect( next );

		Retreat( lane, head, true );
		Retreat( next, foot, false );

		var given = Union( Given( before, Rect( lane ) ), Given( after, Rect( next ) ) );

		return new ArchStairLane
		{
			Step = StairStep.Landing,
			AlongFrom = given.AlongFrom,
			AlongTo = given.AlongTo,
			AcrossFrom = given.AcrossFrom,
			AcrossTo = given.AcrossTo,
			Walk = lane.Walk
		};
	}

	// Taking a platform out of a step that already stands: the step gives up depth at its head and the platform is
	// exactly what it gave. The same move a turn makes on both of its flights, so a landing added later is the
	// landing a turn would have stood - and the shaft and every other step are left where they were.
	public static ArchStairLane Split( ArchStairLane lane, float depth )
	{
		var room = MathF.Min( depth, lane.Length - ArchStairLanes.MinLane );

		if ( room < 0.05f )
		{
			return null;
		}

		var before = Rect( lane );

		Retreat( lane, room, true );

		var given = Given( before, Rect( lane ) );

		return new ArchStairLane
		{
			Step = StairStep.Landing,
			AlongFrom = given.AlongFrom,
			AlongTo = given.AlongTo,
			AcrossFrom = given.AcrossFrom,
			AcrossTo = given.AcrossTo,
			Walk = lane.Walk
		};
	}

	static void Retreat( ArchStairLane lane, float depth, bool head )
	{
		if ( depth < 0.05f )
		{
			return;
		}

		switch ( lane.Walk )
		{
			case StairWalk.Ahead when head:
			case StairWalk.Back when !head:
				lane.AlongTo -= depth;
				break;

			case StairWalk.Back when head:
			case StairWalk.Ahead when !head:
				lane.AlongFrom += depth;
				break;

			case StairWalk.Left when head:
			case StairWalk.Right when !head:
				lane.AcrossTo -= depth;
				break;

			default:
				lane.AcrossFrom += depth;
				break;
		}
	}

	// The strip a step gave up, which is its own rectangle less what it kept. Exactly one of the four numbers
	// moved, so the strip is that number's span across the whole of the other axis.
	static Box Given( Box raw, Box kept )
	{
		if ( kept.AlongTo < raw.AlongTo - 0.05f )
		{
			return raw with { AlongFrom = kept.AlongTo };
		}

		if ( kept.AlongFrom > raw.AlongFrom + 0.05f )
		{
			return raw with { AlongTo = kept.AlongFrom };
		}

		if ( kept.AcrossTo < raw.AcrossTo - 0.05f )
		{
			return raw with { AcrossFrom = kept.AcrossTo };
		}

		return raw with { AcrossTo = kept.AcrossFrom };
	}

	static Box Union( Box one, Box two ) => new(
		MathF.Min( one.AlongFrom, two.AlongFrom ),
		MathF.Max( one.AlongTo, two.AlongTo ),
		MathF.Min( one.AcrossFrom, two.AcrossFrom ),
		MathF.Max( one.AcrossTo, two.AcrossTo ) );

	static Box Rect( ArchStairLane lane ) => new( lane.AlongFrom, lane.AlongTo, lane.AcrossFrom, lane.AcrossTo );

	// ---- The shaft the steps bound ----

	static void Seat( ArchStairCore core, ArchStairAxes frame, IReadOnlyList<ArchStairLane> lanes, Box bounds )
	{
		foreach ( var lane in lanes )
		{
			lane.AlongFrom -= bounds.AlongFrom;
			lane.AlongTo -= bounds.AlongFrom;
			lane.AcrossFrom -= bounds.AcrossFrom;
			lane.AcrossTo -= bounds.AcrossFrom;
		}

		core.Origin = frame.Flat( bounds.AlongFrom, bounds.AcrossFrom );
		core.Length = MathF.Max( ArchStairLanes.MinLane, bounds.AlongTo - bounds.AlongFrom );
		core.Width = MathF.Max( ArchStairLanes.MinLane, bounds.AcrossTo - bounds.AcrossFrom );
	}

	// Measured BEFORE the turns retreat anything, so the shaft still reaches the station a half-landing was
	// reserved out of - a core refitted to the steps alone leaves the platform hanging over its own wall.
	static Box Extents( IEnumerable<ArchStairLane> lanes )
	{
		var alongFrom = float.MaxValue;
		var alongTo = float.MinValue;
		var acrossFrom = float.MaxValue;
		var acrossTo = float.MinValue;

		foreach ( var lane in lanes )
		{
			alongFrom = MathF.Min( alongFrom, lane.AlongFrom );
			alongTo = MathF.Max( alongTo, lane.AlongTo );
			acrossFrom = MathF.Min( acrossFrom, lane.AcrossFrom );
			acrossTo = MathF.Max( acrossTo, lane.AcrossTo );
		}

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

	static ArchStairLane Whole( ArchStairCore core ) => new()
	{
		AlongFrom = 0f,
		AlongTo = core.Length,
		AcrossFrom = 0f,
		AcrossTo = core.Width
	};

	// ---- Reading the clicks ----

	// Square, not however the clicks happened to fall: two points on the grid are almost never exactly on an axis,
	// and a shaft that keeps the couple of degrees between them draws its floor plan standing on a corner. A stair
	// that really does stand at an angle is aimed on the flight afterwards, where the angle is a number.
	static bool Aim( IReadOnlyList<Vector3> points, out float yaw )
	{
		yaw = 0f;

		for ( var index = 0; index + 1 < points.Count; index++ )
		{
			var span = Flat( points[index + 1] ) - Flat( points[index] );

			if ( span.Length < ArchStairLanes.MinLane )
			{
				continue;
			}

			yaw = ArchGridService.Snap( MathF.Atan2( span.y, span.x ).RadianToDegree(), RightAngle );

			return true;
		}

		return false;
	}

	// The clicks land on real surfaces, so a walk that ended a storey up has already said how far it climbs. One
	// that stayed on the floor it started on has not, and falls back to what the sidebar asked for.
	static float Climbed( IReadOnlyList<Vector3> points, float climb )
	{
		var walked = points[^1].z - points[0].z;

		return walked > ArchGridService.FinestSize ? walked : climb;
	}

	static List<Vector2> Local( ArchStairAxes frame, IReadOnlyList<Vector3> points )
	{
		var along = frame.Along;
		var across = frame.Across;

		return points.Select( point =>
		{
			var offset = Flat( point ) - frame.Origin;

			return new Vector2( Vector2.Dot( offset, along ), Vector2.Dot( offset, across ) );
		} ).ToList();
	}

	static Vector2 Flat( Vector3 point ) => new( point.x, point.y );

	static float Half( float width ) => MathF.Max( ArchStairLanes.MinLane, width ) * 0.5f;
}