Editor utility for stair geometry. Computes climb totals and per-lane climbs, step counts, local coordinates, fits/reseats the core frame to lanes, numbers lanes, converts old leg data to lanes, and computes landings between flights.
namespace Sunless.Architecture;
// Everything the core knows about the steps standing in it: how the climb is shared, how many treads a flight
// takes, and how the shaft follows what was drawn in it.
//
// Landings are NOT among them any more. A landing is a step in the list, so the space between two flights is
// authored rather than worked out - which is what lets one be added, moved, resized and deleted where no turn
// happens to be. Settle is the one place the old derive survives, and it runs once per plan.
public static class ArchStairLanes
{
const float Nothing = 0.05f;
public const float MinLane = 18f;
// ---- The climb ----
// The whole climb the core actually stands. Pins RAISE the box rather than being clipped by it, because the
// core follows its lanes - a flight told to climb 96 inches climbs 96 inches.
public static float Climb( ArchStairCore core, IReadOnlyList<ArchStairLane> lanes )
{
var pinned = 0f;
foreach ( var lane in lanes )
{
pinned += MathF.Max( 0f, lane.Rise );
}
return MathF.Max( MathF.Max( 1f, core?.Rise ?? 0f ), pinned );
}
// What each step climbs. A pinned one takes exactly what it was dragged to; the unpinned FLIGHTS share what
// is left in proportion to how far they run, so a long flight takes more of the climb than a short one and
// the risers across an unpinned stair come out even.
//
// An unpinned LANDING takes none, which is what makes it level. Pinning one is what turns it into a split
// level, and it is the same field and the same lift arrow either way.
public static float[] Climbs( ArchStairCore core, IReadOnlyList<ArchStairLane> lanes )
{
var climbs = new float[lanes.Count];
if ( lanes.Count == 0 )
{
return climbs;
}
var pinned = 0f;
var openSpan = 0f;
var openCount = 0;
foreach ( var lane in lanes )
{
if ( lane.Rise > Nothing )
{
pinned += lane.Rise;
continue;
}
if ( !lane.Climbs )
{
continue;
}
openSpan += lane.Length;
openCount++;
}
var free = MathF.Max( 0f, Climb( core, lanes ) - pinned );
for ( var index = 0; index < lanes.Count; index++ )
{
var lane = lanes[index];
if ( lane.Rise > Nothing )
{
climbs[index] = lane.Rise;
continue;
}
if ( !lane.Climbs )
{
continue;
}
climbs[index] = openSpan > Nothing
? free * lane.Length / openSpan
: free / MathF.Max( 1, openCount );
}
return climbs;
}
// The steps a flight takes: enough for the RISER to stay near its target, and enough for the GOING to as
// well - whichever needs more. Treads always tile the drawn run exactly, so the two are one question, and
// taking only the climb would cut a long shallow flight into a handful of enormous treads while taking only
// the run would put a trip at the head of every carve. Both targets are honoured or the deeper count wins,
// which is also what lets a pinned three-riser flight stand beside an unpinned fourteen-riser one.
public static int Steps( float climb, float riser, float length, float going )
{
var byRise = (int)MathF.Round( climb / MathF.Max( 1f, riser ) );
var byRun = (int)MathF.Round( length / MathF.Max( 1f, going ) );
return Math.Max( 1, Math.Max( byRise, Math.Min( byRun, (int)MathF.Floor( climb / ShallowestRiser ) ) ) );
}
// A flight that runs far longer than its climb can carry is a ramp, not a stair: tiling it by the going alone
// cuts a tread every foot for a climb of an inch, which is thousands of faces nobody asked for and what made a
// long flight freeze the tool. This is well under any riser anybody builds, so it only ever bites a flight
// that is already not one - a real stair runs about one and a half times its climb, and this bites at six.
public const float ShallowestRiser = 2f;
public static Vector2 Local( ArchStairCore core, Vector2 point )
{
var axes = core.Axes;
var offset = point - core.Origin;
return new Vector2( Vector2.Dot( offset, axes.Along ), Vector2.Dot( offset, axes.Across ) );
}
// ---- The core follows its lanes ----
// What the steps standing in the shaft actually reach, read off their CORNERS rather than their four numbers -
// a swept segment's footprint is turned about its seat, so the numbers describe where it would have stood.
static (float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo) Bounds( IReadOnlyList<ArchStairLane> lanes )
{
var alongFrom = float.MaxValue;
var alongTo = float.MinValue;
var acrossFrom = float.MaxValue;
var acrossTo = float.MinValue;
foreach ( var corner in lanes.SelectMany( lane => lane.Corners() ) )
{
alongFrom = MathF.Min( alongFrom, corner.x );
alongTo = MathF.Max( alongTo, corner.x );
acrossFrom = MathF.Min( acrossFrom, corner.y );
acrossTo = MathF.Max( acrossTo, corner.y );
}
return (alongFrom, alongTo, acrossFrom, acrossTo);
}
// A lane carved past the box grows the box rather than being clipped by it: the core is a frame that fits what
// is inside it, never a fence a flight has to be drawn around.
public static bool Fit( ArchStairCore core, IReadOnlyList<ArchStairLane> lanes )
{
if ( core is null || lanes.Count == 0 )
{
return false;
}
var (alongFrom, alongTo, acrossFrom, acrossTo) = Bounds( lanes );
alongFrom = MathF.Min( alongFrom, 0f );
acrossFrom = MathF.Min( acrossFrom, 0f );
alongTo = MathF.Max( alongTo, core.Length );
acrossTo = MathF.Max( acrossTo, core.Width );
if ( alongFrom > -Nothing && acrossFrom > -Nothing
&& alongTo - core.Length < Nothing && acrossTo - core.Width < Nothing )
{
return false;
}
core.Origin = core.Flat( alongFrom, acrossFrom );
core.Length = alongTo - alongFrom;
core.Width = acrossTo - acrossFrom;
foreach ( var lane in lanes )
{
lane.AlongFrom -= alongFrom;
lane.AlongTo -= alongFrom;
lane.AcrossFrom -= acrossFrom;
lane.AcrossTo -= acrossFrom;
}
return true;
}
// The shaft laid back onto exactly the steps standing in it. Fit only ever GROWS, which is what a dragged edge
// wants; a turn carries a whole tail somewhere else, and a box that only grows leaves the stair rattling round
// in the shaft the chain needed before the corner went in.
public static void Reseat( ArchStairCore core, IReadOnlyList<ArchStairLane> lanes )
{
if ( core is null || lanes.Count == 0 )
{
return;
}
var (alongFrom, alongTo, acrossFrom, acrossTo) = Bounds( lanes );
core.Origin = core.Flat( alongFrom, acrossFrom );
core.Length = MathF.Max( MinLane, alongTo - alongFrom );
core.Width = MathF.Max( MinLane, acrossTo - acrossFrom );
foreach ( var lane in lanes )
{
lane.AlongFrom -= alongFrom;
lane.AlongTo -= alongFrom;
lane.AcrossFrom -= acrossFrom;
lane.AcrossTo -= acrossFrom;
}
}
// Every step in the climb needs an id of its own, so a railing can name the one it guards and a layer row
// survives being reordered. Allocated late rather than at authoring, because a stair read off disk was
// written before ids existed.
public static void Number( ArchPlan plan, ArchStairPart stair )
{
foreach ( var lane in stair.Lanes.Where( lane => lane.Id == 0 ) )
{
lane.Id = plan.AllocateId();
}
}
// ---- What a stair drawn before landings were steps was ----
// The old model: flights only, with the landing between two of them worked out from what the core had left.
// Read ONCE by ArchPlan.Normalize, which then leaves real landing lanes behind, so nothing downstream ever
// derives a pad again. A stair that already carries one is left alone.
public static bool Settle( ArchPlan plan, ArchStairPart stair )
{
if ( stair.Core is not { } core || stair.Lanes.Count == 0 || stair.Lanes.Any( lane => !lane.Climbs ) )
{
return false;
}
var settled = new List<ArchStairLane>();
for ( var index = 0; index < stair.Lanes.Count; index++ )
{
var lane = stair.Lanes[index];
settled.Add( lane );
if ( index + 1 >= stair.Lanes.Count || Between( core, lane, stair.Lanes[index + 1] ) is not { } pad )
{
continue;
}
settled.Add( new ArchStairLane
{
Id = plan.AllocateId(),
Step = StairStep.Landing,
AlongFrom = pad.AlongFrom,
AlongTo = pad.AlongTo,
AcrossFrom = pad.AcrossFrom,
AcrossTo = pad.AcrossTo,
Walk = lane.Walk
} );
}
foreach ( var lane in settled )
{
lane.LandingDepth = 0f;
}
stair.Lanes.Clear();
stair.Lanes.AddRange( settled );
return true;
}
readonly struct ArchStairLanding
{
public float AlongFrom { get; init; }
public float AlongTo { get; init; }
public float AcrossFrom { get; init; }
public float AcrossTo { get; init; }
}
// The square between two flights, exactly as it was worked out before it became a step you can hold. Where
// their edges already bound a rectangle - a quarter turn's corner - that rectangle IS the landing. Where they
// lie on one line, as two lanes of a switchback do, it grows in the direction the flight below was walking
// until it meets the other flight or the wall of the core.
static ArchStairLanding? Between( ArchStairCore core, ArchStairLane lane, ArchStairLane next )
{
var head = lane.HeadEdge();
var foot = next.FootEdge();
var alongFrom = Least( head.AlongFrom, head.AlongTo, foot.AlongFrom, foot.AlongTo );
var alongTo = Most( head.AlongFrom, head.AlongTo, foot.AlongFrom, foot.AlongTo );
var acrossFrom = Least( head.AcrossFrom, head.AcrossTo, foot.AcrossFrom, foot.AcrossTo );
var acrossTo = Most( head.AcrossFrom, head.AcrossTo, foot.AcrossFrom, foot.AcrossTo );
var heading = lane.Heading;
var alongAxis = MathF.Abs( heading.x ) > 0.5f;
if ( alongAxis && alongTo - alongFrom < Nothing )
{
var line = head.AlongFrom;
var depth = Depth( lane, Reach( core, lane, next, line, heading.x, acrossFrom, acrossTo, true ) );
if ( depth < Nothing )
{
return null;
}
alongFrom = heading.x > 0f ? line : line - depth;
alongTo = heading.x > 0f ? line + depth : line;
}
else if ( !alongAxis && acrossTo - acrossFrom < Nothing )
{
var line = head.AcrossFrom;
var depth = Depth( lane, Reach( core, lane, next, line, heading.y, alongFrom, alongTo, false ) );
if ( depth < Nothing )
{
return null;
}
acrossFrom = heading.y > 0f ? line : line - depth;
acrossTo = heading.y > 0f ? line + depth : line;
}
if ( alongTo - alongFrom < Nothing || acrossTo - acrossFrom < Nothing )
{
return null;
}
return new ArchStairLanding
{
AlongFrom = alongFrom,
AlongTo = alongTo,
AcrossFrom = acrossFrom,
AcrossTo = acrossTo
};
}
static float Depth( ArchStairLane lane, float reach )
{
return lane.LandingDepth > Nothing ? MathF.Min( lane.LandingDepth, reach ) : reach;
}
// ONLY the pair is asked: a lane elsewhere in the chain stands at another height, and a multi-storey
// switchback re-uses the same two rectangles flight after flight.
static float Reach( ArchStairCore core, ArchStairLane lane, ArchStairLane next, float line, float direction, float from, float to, bool alongAxis )
{
var limit = direction > 0f ? alongAxis ? core.Length : core.Width : 0f;
foreach ( var candidate in new[] { lane, next } )
{
var crossFrom = alongAxis ? candidate.AcrossFrom : candidate.AlongFrom;
var crossTo = alongAxis ? candidate.AcrossTo : candidate.AlongTo;
if ( crossTo <= from + Nothing || crossFrom >= to - Nothing )
{
continue;
}
var near = alongAxis ? candidate.AlongFrom : candidate.AcrossFrom;
var far = alongAxis ? candidate.AlongTo : candidate.AcrossTo;
if ( direction > 0f && near >= line - Nothing )
{
limit = MathF.Min( limit, near );
}
if ( direction < 0f && far <= line + Nothing )
{
limit = MathF.Max( limit, far );
}
}
return MathF.Max( 0f, MathF.Abs( limit - line ) );
}
// ---- What a drawn chain of boxes was ----
// The oldest authoring model, read once on load: the bounds of every leg become the core and each leg becomes
// the flight it always was. A chain drawn at an angle keeps the first leg's yaw, which is the frame it was
// aimed on.
public static (ArchStairCore Core, List<ArchStairLane> Lanes) FromLegs( IReadOnlyList<ArchStairLeg> legs )
{
var core = new ArchStairCore { Yaw = legs.Count > 0 ? legs[0].Yaw : 0f, Origin = legs.Count > 0 ? legs[0].Start : Vector2.Zero };
var lanes = new List<ArchStairLane>( legs.Count );
if ( legs.Count == 0 )
{
return (core, lanes);
}
var frame = core.Axes;
var alongFrom = float.MaxValue;
var alongTo = float.MinValue;
var acrossFrom = float.MaxValue;
var acrossTo = float.MinValue;
var boxes = new List<(float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo, StairWalk Walk)>();
foreach ( var leg in legs )
{
var width = leg.Width > 1f ? leg.Width : 48f;
var corners = leg.Axes.Rect( 0f, leg.Length, 0f, width );
var lowAlong = float.MaxValue;
var highAlong = float.MinValue;
var lowAcross = float.MaxValue;
var highAcross = float.MinValue;
foreach ( var corner in corners )
{
var offset = corner - core.Origin;
var along = Vector2.Dot( offset, frame.Along );
var across = Vector2.Dot( offset, frame.Across );
lowAlong = MathF.Min( lowAlong, along );
highAlong = MathF.Max( highAlong, along );
lowAcross = MathF.Min( lowAcross, across );
highAcross = MathF.Max( highAcross, across );
}
var travel = leg.Span.IsNearZeroLength ? frame.Along : leg.Span.Normal;
var forward = Vector2.Dot( travel, frame.Along );
var sideways = Vector2.Dot( travel, frame.Across );
boxes.Add( (lowAlong, highAlong, lowAcross, highAcross, MathF.Abs( sideways ) > MathF.Abs( forward )
? sideways >= 0f ? StairWalk.Left : StairWalk.Right
: forward >= 0f ? StairWalk.Ahead : StairWalk.Back) );
alongFrom = MathF.Min( alongFrom, lowAlong );
alongTo = MathF.Max( alongTo, highAlong );
acrossFrom = MathF.Min( acrossFrom, lowAcross );
acrossTo = MathF.Max( acrossTo, highAcross );
}
core.Origin = frame.Flat( alongFrom, acrossFrom );
core.Length = MathF.Max( 1f, alongTo - alongFrom );
core.Width = MathF.Max( 1f, acrossTo - acrossFrom );
foreach ( var box in boxes )
{
lanes.Add( new ArchStairLane
{
AlongFrom = box.AlongFrom - alongFrom,
AlongTo = box.AlongTo - alongFrom,
AcrossFrom = box.AcrossFrom - acrossFrom,
AcrossTo = box.AcrossTo - acrossFrom,
Walk = box.Walk
} );
}
return (core, lanes);
}
static float Least( float a, float b, float c, float d ) => MathF.Min( MathF.Min( a, b ), MathF.Min( c, d ) );
static float Most( float a, float b, float c, float d ) => MathF.Max( MathF.Max( a, b ), MathF.Max( c, d ) );
}