Editor/Stair/ArchStairHousing.cs

Editor helper classes for stair placement in architectural generation. ArchStairHousing computes how a drawn stair fits against building shell and trims steps that cross walls; ArchStairHousings caches per-storey housings.

File Access
namespace Sunless.Architecture;

// A step taken back to the side of the shell it stands on: its head and both its flanks, in the frame it was
// drawn in. The foot never moves - it is where the author put the stair down, and moving it would take the
// bottom of the climb with it.
public readonly record struct ArchStairFit( float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo );

// The building a stair stands in, and the one answer to where a step may reach inside it.
//
// A STEP IS ONE SIDE OF THE SHELL OR THE OTHER AND NEVER BOTH, and the side holding MOST of it wins. What
// crosses over is taken off, so a flight run down the outside of a wall stops at the plaster instead of burying
// its treads in the room behind it, and one drawn out of a room ends against the wall instead of walking
// through it. The stairwell, the ring railing and the archways all follow with no second rule anywhere, because
// every one of them is cut from what the steps cover.
//
// Only the SHELL bounds a step. A wall with room on both sides is a partition, and a flight walks through one and
// breaches it exactly as it always did - that is what a stairwell in a house does. So the question asked of every
// wall on the storey is whether the building stands on one side of it and nothing on the other.
public sealed class ArchStairHousing
{
	// A step's own footprint sampled this many ways along each axis to say which side holds more of it. Coarser
	// reads a flight half over a wall as wholly on whichever side its centre happens to fall.
	const int Samples = 7;

	readonly List<IReadOnlyList<Vector2>> region = new();
	readonly List<ArchWall> shell = new();
	readonly ArchKit kit;

	ArchStairHousing( ArchKit kit )
	{
		this.kit = kit;
	}

	public static ArchStairHousing On( IEnumerable<ArchRoom> storey, int level, ArchKit kit, ArchKinds kinds )
	{
		var housing = new ArchStairHousing( kit );

		if ( storey is null )
		{
			return housing;
		}

		var rooms = storey.Where( entry => entry.Floor == level ).ToList();
		var loops = rooms.Select( ArchFloorGen.Footprint ).Where( loop => loop.Count >= 3 ).ToList();

		if ( loops.Count == 0 )
		{
			return housing;
		}

		// A non-rectilinear room has no grid to merge on, so it keeps its own loop - the same split ArchFloorGen
		// makes when it groups a storey's slabs, and for the same reason.
		housing.region.AddRange( loops.All( ArchFootprint.IsRectilinear )
			? ArchFootprint.Union( loops )
			: loops );

		foreach ( var room in rooms )
		{
			foreach ( var wall in ArchPlanStore.FiledOn( ArchKind.Wall, room, kinds ).OfType<ArchWall>() )
			{
				if ( Bounding( housing.region, wall ) )
				{
					housing.shell.Add( wall );
				}
			}
		}

		return housing;
	}

	public ArchStairFit Fit( ArchStairAxes axes, float alongFrom, float alongTo, float acrossFrom, float acrossTo )
	{
		var drawn = new ArchStairFit( alongFrom, alongTo, acrossFrom, acrossTo );

		if ( shell.Count == 0 || alongTo - alongFrom < 0.5f || acrossTo - acrossFrom < 0.5f )
		{
			return drawn;
		}

		var seat = Seat( axes, drawn );

		// The step is wholly on one side already, and there is nothing for the shell to take off it.
		if ( !seat.Crossed )
		{
			return drawn;
		}

		var seed = axes.Flat( seat.Along, seat.Across );

		var head = seat.Along + Bounded( seed, axes.Along, alongTo - seat.Along );
		var left = seat.Across + Bounded( seed, axes.Across, acrossTo - seat.Across );
		var right = seat.Across - Bounded( seed, -axes.Across, seat.Across - acrossFrom );

		// Nothing the shell takes off a step may take it under the shortest one the tool will stand, or a flight
		// sitting a hair over a wall comes back as no flight at all. It is widened back toward the step as it was
		// drawn, never out past it.
		head = MathF.Max( head, alongFrom + MathF.Min( ArchStairLanes.MinLane, alongTo - alongFrom ) );

		var least = MathF.Min( ArchStairLanes.MinLane, acrossTo - acrossFrom );

		if ( left - right < least )
		{
			left = MathF.Min( acrossTo, right + least );
			right = MathF.Max( acrossFrom, left - least );
		}

		return new ArchStairFit( alongFrom, head, right, left );
	}

	// Where the step mostly stands, and whether it is over the shell at all. The samples on the winning side
	// average out to a point that side genuinely holds, which is what every probe is then fired from - a plain
	// centre lands in the wall itself on the step this exists to fix.
	(float Along, float Across, bool Crossed) Seat( ArchStairAxes axes, ArchStairFit drawn )
	{
		var inside = 0;
		var outside = 0;
		var held = Vector2.Zero;
		var away = Vector2.Zero;

		for ( var alongStep = 0; alongStep < Samples; alongStep++ )
		{
			var along = MathX.Lerp( drawn.AlongFrom, drawn.AlongTo, (alongStep + 0.5f) / Samples );

			for ( var acrossStep = 0; acrossStep < Samples; acrossStep++ )
			{
				var across = MathX.Lerp( drawn.AcrossFrom, drawn.AcrossTo, (acrossStep + 0.5f) / Samples );
				var at = new Vector2( along, across );

				if ( ArchFootprint.Encloses( region, axes.Flat( along, across ) ) )
				{
					inside++;
					held += at;
				}
				else
				{
					outside++;
					away += at;
				}
			}
		}

		if ( inside == 0 || outside == 0 )
		{
			return (0f, 0f, false);
		}

		var winner = inside >= outside ? held / inside : away / outside;

		return (winner.x, winner.y, true);
	}

	// How far the shell stands off the seed that way, capped by how far the step reached anyway. A probe that
	// started inside a wall's own thickness answers negative - a flight flush against the shell is not a flight
	// of nothing.
	float Bounded( Vector2 seed, Vector2 heading, float limit )
	{
		if ( limit <= 0f )
		{
			return 0f;
		}

		var reach = ArchProbe.Reach( shell, kit, seed, heading, limit );

		return reach >= limit ? limit : MathF.Max( 0f, reach );
	}

	static bool Bounding( IReadOnlyList<IReadOnlyList<Vector2>> region, ArchWall wall )
	{
		return wall.Length >= 1f
			&& ArchProbe.Faces( region, wall.Start, wall.End, wall.Normal )
			!= ArchProbe.Faces( region, wall.Start, wall.End, -wall.Normal );
	}
}

// Every storey a climb passes through, resolved on first ask and kept for the rest of that resolve. A stair of
// six steps in one room would otherwise walk the storey's rooms and walls six times over to be told the same
// thing each time.
public sealed class ArchStairHousings
{
	readonly Dictionary<int, ArchStairHousing> standing = new();
	readonly IEnumerable<ArchRoom> storey;
	readonly ArchKit kit;
	readonly ArchKinds kinds;

	public ArchStairHousings( IEnumerable<ArchRoom> storey, ArchKit kit, ArchKinds kinds )
	{
		this.storey = storey;
		this.kit = kit;
		this.kinds = kinds;
	}

	public ArchStairHousing On( int level )
	{
		if ( !standing.TryGetValue( level, out var housing ) )
		{
			housing = ArchStairHousing.On( storey, level, kit, kinds );
			standing[level] = housing;
		}

		return housing;
	}
}