Editor-side partial of ArchStairShape and a small ArchStairMouth record. Implements geometric queries for stair footprints, loops, trimmed areas, whether a point lies alongside or on a landing, seam insertion along edges, which edges need guarding, and computation of stair mouths for a given floor height.
using System;
using System.Linq;
using System.Collections.Generic;
using Sandbox;
namespace Sunless.Architecture;
public sealed partial class ArchStairShape
{
// Every square the flight stands on, in plan - what a wall wrap is built round.
public IEnumerable<IReadOnlyList<Vector2>> Footprint()
{
foreach ( var run in Runs )
{
yield return run.Loop();
}
foreach ( var pad in Pads )
{
yield return pad.Loop();
}
}
// Every loop the stair owns, any floor - what pick, report and locate ask over.
public IEnumerable<List<Vector2>> Wells()
{
foreach ( var well in Levels )
{
foreach ( var loop in well.Loops )
{
yield return loop;
}
}
}
// Trim stops along flights and round landings — a nosing there reads as a board on the floor.
public static Func<Vector2, bool> Trimmed( ArchBuilding building, ArchKit kit, IReadOnlyList<ArchFloorCutout> wells, float height )
{
var owners = wells.Select( well => well.OwnerId ).Where( id => id != 0 ).ToHashSet();
if ( building is null || owners.Count == 0 )
{
return null;
}
var flights = new List<ArchStairShape>();
var kinds = ArchKinds.Load();
foreach ( var room in building.Rooms )
{
foreach ( var stair in ArchPlanStore.FiledOn( ArchKind.Stair, room, kinds ).OfType<ArchStairPart>().Where( stair => owners.Contains( stair.Id ) ) )
{
flights.Add( Resolve( stair, room, kit, building.Rooms, building ) );
}
}
return flights.Count == 0
? null
: point => flights.Any( flight => flight.Alongside( point, height ) || flight.Lands( point, height ) );
}
// Geometry only, on this floor - a raked cut edge below the slab is someone else's trim.
public bool Alongside( Vector2 point, float height )
{
foreach ( var run in Runs )
{
if ( height < run.BaseHeight - 1f || height > run.TopHeight + 1f )
{
continue;
}
var local = point - run.Axes.Origin;
var along = Vector2.Dot( local, run.Axes.Along );
if ( along < -1f || along > run.Length + 1f )
{
continue;
}
var across = Vector2.Dot( local, run.Axes.Across );
if ( MathF.Abs( across ) < 1f || MathF.Abs( across - run.Width ) < 1f )
{
return true;
}
}
return false;
}
// Rectangle test, not containment: the probe point sits exactly on the pad's edge.
public bool Lands( Vector2 point, float height )
{
foreach ( var pad in Pads )
{
if ( MathF.Abs( pad.Height - height ) > 1f )
{
continue;
}
var local = point - pad.Axes.Origin;
var along = Vector2.Dot( local, pad.Axes.Along );
var across = Vector2.Dot( local, pad.Axes.Across );
if ( along > pad.AlongFrom - 1f && along < pad.AlongTo + 1f
&& across > pad.AcrossFrom - 1f && across < pad.AcrossTo + 1f )
{
return true;
}
}
return false;
}
// The union merges flight and landing sides into ONE edge — each half wants a different rail. Nothing
// splits a flank part way along any more: the whole of it is the flight's rail's, so a break there would
// only stand the ring's two stretches a post apart with no post between them.
public List<Vector2> Seamed( IReadOnlyList<Vector2> loop )
{
var breaks = new List<Vector2>();
foreach ( var run in Runs )
{
breaks.AddRange( run.Loop() );
}
foreach ( var pad in Pads )
{
breaks.AddRange( pad.Loop() );
}
var seamed = new List<Vector2>();
for ( var index = 0; index < loop.Count; index++ )
{
var a = loop[index];
var b = loop[(index + 1) % loop.Count];
var span = (b - a).Length;
seamed.Add( a );
if ( span < 1f )
{
continue;
}
var unit = (b - a) / span;
var cuts = new List<float>();
foreach ( var point in breaks )
{
var at = Vector2.Dot( point - a, unit );
if ( at <= 0.5f || at >= span - 0.5f || (point - (a + unit * at)).Length > 0.1f )
{
continue;
}
if ( cuts.Any( existing => MathF.Abs( existing - at ) < 0.5f ) )
{
continue;
}
cuts.Add( at );
}
foreach ( var at in cuts.OrderBy( value => value ) )
{
seamed.Add( a + unit * at );
}
}
return seamed;
}
// Only two edges are bare: the mouth the flight arrives through, and anything against a wall.
// A flank is NOT bare — near the foot the rake is a storey below the walker.
public bool[] GuardedEdges( IReadOnlyList<Vector2> loop, ArchRoom probe, ArchKit kit, float height )
{
var guarded = new bool[loop.Count];
var mouths = Mouths( kit, height );
var post = MathF.Max( 1f, kit.NewelSize );
var walls = ArchPlanStore.FiledOn( ArchKind.Wall, probe ).OfType<ArchWall>().ToList();
// Counter-clockwise loop: void on the left, floor on the right.
var floor = ArchFootprint.FloorSide( loop );
for ( var index = 0; index < loop.Count; index++ )
{
var a = loop[index];
var b = loop[(index + 1) % loop.Count];
var middle = (a + b) * 0.5f;
var unit = (b - a).Normal;
// Toward the FLOOR, not into the hole. A wall that makes a rail pointless stands on the
// side you walk on - probing the void behind the edge finds nothing ever, so the test
// always said "no wall" and every well edge got railed, walls included.
var toward = new Vector2( -unit.y, unit.x ) * floor;
guarded[index] = (b - a).Length > post
&& !mouths.Any( mouth => mouth.Holds( middle ) )
&& !HandedToRail( middle, height )
&& !ArchProbe.Against( walls, kit, middle, toward );
}
return guarded;
}
// An edge the flight's own rail already guards — the ring on the floor above must not double it. A flank is
// the flight's only where its rake climbs PAST this floor and so stands beside the walker up here. A run
// topping out ON this floor keeps its rake under the boards the whole way along that flank, so the drop
// belongs to the ring, which is what turns the guard back over the well and round to the other side. The one
// condition is that the rail EXISTS on this flank - guard on and not against a wall - so a bare or walled
// side still hands its drop to the ring.
bool HandedToRail( Vector2 point, float height )
{
foreach ( var run in Runs )
{
if ( height < run.BaseHeight - 1f || height > run.TopHeight + 1f || run.TopHeight < height + 1f )
{
continue;
}
var local = point - run.Axes.Origin;
var along = Vector2.Dot( local, run.Axes.Along );
if ( along < -1f || along > run.Length + 1f )
{
continue;
}
var across = Vector2.Dot( local, run.Axes.Across );
if ( MathF.Abs( across ) < 1f && GuardRight && !run.WalledRight )
{
return true;
}
if ( MathF.Abs( across - run.Width ) < 1f && GuardLeft && !run.WalledLeft )
{
return true;
}
}
return Turned( point, height );
}
// The landing at the head of a flight is the walk's too - it is what the rake turns onto. Its own railed
// edges carry the rail round onto the storey above, so the ring must leave them alone or the quarter turn
// comes out twice, a rail's width apart.
bool Turned( Vector2 point, float height )
{
foreach ( var pad in Pads )
{
if ( MathF.Abs( pad.Height - height ) > 1f )
{
continue;
}
var local = point - pad.Axes.Origin;
var along = Vector2.Dot( local, pad.Axes.Along );
var across = Vector2.Dot( local, pad.Axes.Across );
if ( along < pad.AlongFrom - 1f || along > pad.AlongTo + 1f
|| across < pad.AcrossFrom - 1f || across > pad.AcrossTo + 1f )
{
continue;
}
if ( MathF.Abs( across - pad.AcrossFrom ) < 1f && pad.RailsFrom )
{
return true;
}
if ( MathF.Abs( across - pad.AcrossTo ) < 1f && pad.RailsTo )
{
return true;
}
if ( MathF.Abs( along - pad.AlongTo ) < 1f && pad.RailsHead )
{
return true;
}
}
return false;
}
// Every way off the stair at this floor, as the ground each one actually covers. Which way a landing leaves
// follows its turn: a straight-through one walks off its far end, a turn leaves sideways through the flank the
// next run steps off. A flight that arrives at the floor with nothing to stand on leaves over its own head.
List<ArchStairMouth> Mouths( ArchKit kit, float height )
{
var clearance = MathF.Max( 1f, kit.StairWalkClearance );
var mouths = new List<ArchStairMouth>();
foreach ( var pad in Pads )
{
if ( MathF.Abs( pad.Height - height ) > 1f )
{
continue;
}
mouths.Add( pad.Turn switch
{
StairTurn.Right => new ArchStairMouth( pad.Axes, pad.AlongFrom, pad.AlongTo, pad.AcrossFrom - clearance, pad.AcrossFrom ),
StairTurn.Left => new ArchStairMouth( pad.Axes, pad.AlongFrom, pad.AlongTo, pad.AcrossTo, pad.AcrossTo + clearance ),
_ => new ArchStairMouth( pad.Axes, pad.AlongTo, pad.AlongTo + clearance, pad.AcrossFrom, pad.AcrossTo )
} );
}
foreach ( var run in Runs )
{
if ( MathF.Abs( run.TopHeight - height ) < 1f )
{
mouths.Add( new ArchStairMouth( run.Axes, run.Length, run.Length + clearance, 0f, run.Width ) );
}
}
return mouths;
}
}
// The ground a way off the stair covers: the width of the walk, carried the kit's clearance ONTO the floor. The
// mouth is a reach rather than an edge because a rail standing a step past the head blocks the stair exactly as
// surely as one standing on it - the ring has to leave the whole of the walk alone, not just the line it crosses.
public readonly record struct ArchStairMouth( ArchStairAxes Axes, float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo )
{
public bool Holds( Vector2 point )
{
var local = point - Axes.Origin;
var along = Vector2.Dot( local, Axes.Along );
var across = Vector2.Dot( local, Axes.Across );
return along > AlongFrom - 1f && along < AlongTo + 1f
&& across > AcrossFrom - 1f && across < AcrossTo + 1f;
}
}