Editor-side partial class for ArchStairShape. Contains helper functions for stair geometry (Riser, PadWell, PadLevel) and a Validate method that produces user-facing warnings about stair configuration relative to building/kit constraints.
namespace Sunless.Architecture;
public sealed partial class ArchStairShape
{
// Back to the riser line under it - a slab edge across the middle of a tread is built nowhere.
static float Riser( ArchStairRun run, float station )
{
var going = MathF.Max( 1f, run.Going );
return MathF.Floor( station / going ) * going;
}
// A pad AT a floor opens that floor's slab; one floating mid-storey opens the slab above it.
// FloorIndex ROUNDS to the nearest storey, so a landing past halfway up one already reads as the
// floor above - stepping it again put its hole through the slab after the one it sits under.
static int PadWell( ArchBuilding building, ArchKit kit, ArchStairPad pad )
{
var floor = FloorIndex( building, kit, pad.Height );
var seat = FloorOf( building, kit, floor );
if ( MathF.Abs( pad.Height - seat ) < 1f )
{
return floor;
}
return pad.Height > seat ? floor + 1 : floor;
}
static int? PadLevel( ArchBuilding building, ArchKit kit, float height )
{
var floor = FloorIndex( building, kit, height );
return MathF.Abs( height - FloorOf( building, kit, floor ) ) < 1f ? floor : null;
}
// The live checks, handed the resolve rather than taking their own: this runs where the sidebar rebuilds,
// and a second walk is a second answer waiting to disagree with the first.
public static List<string> Validate( ArchStairPart stair, ArchStairShape shape, ArchKit kit, ArchBuilding building )
{
var warnings = new List<string>();
var going = shape.Going;
foreach ( var run in shape.Runs )
{
var next = FloorOf( building, kit, run.Level + 1 );
if ( run.TopHeight > next + 1f )
{
warnings.Add( $"the flight from floor {run.Level + 1} climbs past floor {run.Level + 2} without a landing — carve a shorter lane so the climb steps off there" );
}
if ( run.Going < going * 0.5f )
{
warnings.Add( $"flight {run.Index + 1} tiles {run.Steps} treads into {run.Length:0} — {run.Going:0.#} deep against the {going:0.#} asked for; drag that lane longer or pin it to less climb" );
}
}
// Every flight holding its own rise is the point of the model, but two flights of a stair a walker
// crosses in one go should still feel the same underfoot.
if ( shape.Runs.Count > 1 )
{
var shallow = shape.Runs.Min( run => run.Rise );
var steep = shape.Runs.Max( run => run.Rise );
if ( steep - shallow > 1f )
{
warnings.Add( $"the risers vary from {shallow:0.#} to {steep:0.#} between flights — clear a pinned flight's climb to let it divide with the rest" );
}
}
var head = shape.TopHeight;
var headFloor = FloorOf( building, kit, shape.TopLevel );
if ( MathF.Abs( head - headFloor ) > 1f )
{
warnings.Add( $"the climb ends at {head:0}, between floors — pull the head arrow to a storey to land on one" );
}
if ( shape.Rise > kit.StepRise * 1.75f )
{
warnings.Add( $"the risers come out {shape.Rise:0.#} deep — the core is too short for the climb, so lengthen it or lower the head" );
}
if ( shape.Runs.Count == 1 && shape.Runs[0].Length < going * 2f )
{
warnings.Add( "a stair needs at least a couple of steps — drag a longer core" );
}
return warnings;
}
}