Editor/Stair/ArchStairHousing.cs
namespace Sunless.Architecture;
// A step fitted to the shell side it stands on — head and flanks, in the drawn frame.
public readonly record struct ArchStairFit( float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo );
// Where a step may reach: each step belongs to ONE side of the shell (majority wins), partitions are ignored.
public sealed class ArchStairHousing {
// Sample density per axis for majority-side detection.
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( ArchFootprint.Bearing( loops, out var bearing )
&& loops.All( loop => ArchFootprint.IsRectilinear( loop, bearing ) )
? 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;
}
}