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.
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;
}
}