Editor code for stair step layout in an architectural planning system. It provides operations for finding a stair owner and home room, manipulating individual stair lanes (add, remove, move, split into segments/curve, join to stations), computing geometry (seating, laying rectangles, stations), and keeping chains of relative turns and bearings in sync when steps move.
namespace Sunless.Architecture;
// A place on a step where the next one may stand, and the way it walks when it stands there - the outward side of
// the step it joins, so a flight put on the left flank leaves to the left and one put on the head carries straight
// on. The point alone would place a step and leave it facing whatever way it happened to be facing.
public readonly record struct ArchStairStation( Vector2 At, StairWalk Walk );
// What a single step of a climb can be asked, once the stack lets one be held on its own. The stair it belongs to
// is looked up rather than carried, because a step is filed on the stair's own Lanes list and nothing else in the
// plan holds one - so there is exactly one owner to find and no context to thread through the tree.
public static class ArchStairSteps
{
public static ArchStairPart Owner( ArchPlan plan, ArchStairLane lane )
{
if ( plan is null || lane is null )
{
return null;
}
return plan.Parts<ArchStairPart>().FirstOrDefault( stair => stair.Lanes.Contains( lane ) );
}
public static int IndexOf( ArchStairPart stair, ArchStairLane lane )
{
return stair?.Lanes.IndexOf( lane ) ?? -1;
}
// The room the stair is FILED on - its storey, its floor height and the building whose slabs it opens. Asked
// wherever a stair has to be put back in its context, because a stair carries no room of its own.
public static ArchRoom Home( ArchPlan plan, ArchStairPart stair )
{
if ( plan is null || stair is null )
{
return null;
}
var kinds = ArchKinds.Load();
return plan.AllRooms().FirstOrDefault( room => plan.Filed<ArchStairPart>( room, kinds ).Contains( stair ) );
}
// Where a step's head was dragged to, as a climb. A stair of ONE step has no share to take - its climb IS the
// shaft's - so dragging its head moves the box rather than pinning a lane against it and leaving the caption
// saying one figure while the treads stand at another.
public static void Lift( ArchStairPart stair, ArchStairLane lane, float climb )
{
if ( stair.Lanes.Count == 1 && stair.Core is { } core )
{
core.Rise = MathF.Max( 1f, climb );
lane.Rise = 0f;
return;
}
lane.Rise = MathF.Max( 0f, climb );
}
// Which flight or platform this is when the stack counts them - the row says "Flight 2", and a stair with a
// platform in the middle must not call its second flight the third step.
public static int Ordinal( ArchStairPart stair, ArchStairLane lane )
{
var count = 0;
foreach ( var step in stair.Lanes )
{
if ( step.Climbs == lane.Climbs )
{
count++;
}
if ( step == lane )
{
return count;
}
}
return count;
}
// ---- A step standing off the one below it ----
// A step laid against the head of the one below it, turned however it turns. A quarter turn takes its width out
// of that head, a switchback stands beside it and a straight run carries on - so two steps always abut, the
// corner is square whichever way it goes, and there is no gap for a walker to fall through. The step below's own
// BEARING carries, so a run standing off a swept segment leaves at the angle that segment arrived at.
static void Stand( ArchStairLane lane, ArchStairLane onto, StairWalk walk, float run, float width )
{
Lay( lane, Seated( onto, walk, width ), walk, run, width, onto.Bearing );
}
// WHERE a turn seats the step above - the joint's own point, in the frame the step below actually stands in, so
// it follows every rotation rather than being four positions on an axis. One answer, asked both by standing a
// step and by reading back which turn a DRAGGED one now describes.
static Vector2 Seated( ArchStairLane onto, StairWalk walk, float width )
{
var heading = onto.Heading;
var hand = onto.Leftward;
var head = onto.Seat + heading * onto.Length;
return onto.Walk.Between( walk ) switch
{
StairTurn.Left => head + hand * onto.Width,
StairTurn.Right => onto.Seat + heading * MathF.Max( 0f, onto.Length - width ),
StairTurn.Back => head + hand * (onto.Width + width),
_ => head
};
}
// The rectangle a run and a width come to, read off the corner the walk leaves from. The four numbers are the
// step's UNTURNED footprint - a bearing spins that footprint about the seat, which is the corner they are laid
// out of, so the run and the width survive the turn and only the corners move.
static void Lay( ArchStairLane lane, Vector2 seat, StairWalk walk, float run, float width, float bearing )
{
var reach = seat + walk.Heading() * MathF.Max( ArchStairLanes.MinLane, run )
+ walk.Leftward() * MathF.Max( ArchStairLanes.MinLane, width );
lane.Walk = walk;
lane.Bearing = bearing;
lane.AlongFrom = MathF.Min( seat.x, reach.x );
lane.AlongTo = MathF.Max( seat.x, reach.x );
lane.AcrossFrom = MathF.Min( seat.y, reach.y );
lane.AcrossTo = MathF.Max( seat.y, reach.y );
}
// Another flight at the head of the climb, walking the way the step it follows walked until it is turned. It
// takes no share of the climb of its own, so a stair told to reach a storey still reaches that storey - the
// flights in it simply divide the rise between more of them.
public static ArchStairLane Add( ArchPlan plan, ArchStairPart stair, ArchStairLane after )
{
var index = after is null ? stair.Lanes.Count - 1 : IndexOf( stair, after );
if ( index < 0 || stair.Core is null )
{
return null;
}
var below = stair.Lanes[index];
var flight = new ArchStairLane { Id = plan.AllocateId(), Step = StairStep.Flight };
var chain = ArchStairChain.Of( stair );
Stand( flight, below, below.Walk, Run( stair, index ), below.Width );
stair.Lanes.Insert( index + 1, flight );
chain.Insert( index + 1 );
Relay( stair, index + 2, chain );
ArchStairLanes.Reseat( stair.Core, stair.Lanes );
return flight;
}
// What a fresh flight runs: as far as the last flight below it went, so a switchback comes out over its own
// footprint rather than needing to be dragged back to it.
static float Run( ArchStairPart stair, int index )
{
for ( var step = index; step >= 0; step-- )
{
if ( stair.Lanes[step].Climbs )
{
return stair.Lanes[step].Length;
}
}
return MathF.Max( ArchStairLanes.MinLane, stair.Core.Length );
}
// The turn a step makes off the one below it. Every step ABOVE follows, because a chain that keeps its old
// numbers when a corner is put in the middle of it is a chain with a hole in it.
public static bool Aim( ArchStairPart stair, ArchStairLane lane, StairTurn turn )
{
var index = IndexOf( stair, lane );
if ( index <= 0 || stair.Core is null )
{
return false;
}
var below = stair.Lanes[index - 1];
var chain = ArchStairChain.Of( stair );
Stand( lane, below, below.Walk.Turned( turn ), lane.Length, lane.Width );
Relay( stair, index + 1, chain );
ArchStairLanes.Reseat( stair.Core, stair.Lanes );
return true;
}
// A flight cut into equal segments, each a flight in its own right. That is what a swept stair is in a shaft
// model: every segment is turned, widened and dragged on the plan on its own, and a chain of them walks round a
// corner in as many bites as it was cut into. The climb divides with the run, so the risers stay even.
public static bool Segment( ArchPlan plan, ArchStairPart stair, ArchStairLane lane, int count )
{
return Segment( plan, stair, lane, count, 0f );
}
// The same cut, fanned: the first segment leaves at the angle the flight already had and each one after it turns
// by an equal share, so the last arrives turned by the whole SWEEP. That is a curved stair - the run is drawn
// where it starts, the sweep says where it ends up pointing, and the segment count is how round it reads.
public static bool Curve( ArchPlan plan, ArchStairPart stair, ArchStairLane lane, int count, float sweep )
{
return count >= 2 && Segment( plan, stair, lane, count, sweep );
}
static bool Segment( ArchPlan plan, ArchStairPart stair, ArchStairLane lane, int count, float sweep )
{
var index = IndexOf( stair, lane );
if ( index < 0 || !lane.Climbs || count < 2 || lane.Length / count < ArchStairLanes.MinLane )
{
return false;
}
var guards = lane.Guards.ToList();
var chain = ArchStairChain.Of( stair );
var run = lane.Length / count;
var width = lane.Width;
var walk = lane.Walk;
var bearing = lane.Bearing;
var pinned = lane.Rise;
var turn = sweep / (count - 1);
var cut = new List<ArchStairLane>();
for ( var part = 0; part < count; part++ )
{
var piece = part == 0 ? lane : new ArchStairLane { Id = plan.AllocateId(), Step = StairStep.Flight };
if ( part == 0 )
{
Lay( piece, lane.Seat, walk, run, width, bearing );
}
else
{
// Seated on the segment below at the angle THAT one arrived at, and only then turned its own
// share - which is what makes the chain an arc rather than a fan of runs from one point.
Stand( piece, cut[part - 1], walk, run, width );
piece.Bearing = bearing + turn * part;
}
piece.Rise = pinned / count;
piece.Guards = Slice( plan, guards, part, count );
cut.Add( piece );
}
stair.Lanes.RemoveAt( index );
stair.Lanes.InsertRange( index, cut );
chain.Insert( index + 1, count - 1 );
Relay( stair, index + count, chain );
ArchStairLanes.Reseat( stair.Core, stair.Lanes );
return true;
}
// A railing that ran the whole flight runs the whole of every segment, and one that covered a stretch keeps the
// stretch it covered - read back as a fraction of the segment it lands on.
static List<ArchStairGuardPart> Slice( ArchPlan plan, IReadOnlyList<ArchStairGuardPart> guards, int part, int count )
{
var from = part / (float)count;
var to = (part + 1) / (float)count;
var kept = new List<ArchStairGuardPart>();
foreach ( var guard in guards )
{
var start = MathF.Max( guard.Start, from );
var end = MathF.Min( guard.End, to );
if ( end - start < 0.01f )
{
continue;
}
var slice = guard.Copy();
slice.Id = plan.AllocateId();
slice.From = (start - from) * count;
slice.To = (end - from) * count;
kept.Add( slice );
}
return kept;
}
// What a chain IS, read before anything in it moves: the JOINT each step makes off the one below it - its
// quarter turn - and the BEARING it makes off it, which is the share of a sweep it carries.
//
// Both are relative, and that is the whole point. A flight that carried straight on has to carry straight on
// after the corner below it swings, or turning one step silently puts a second turn in the one above it; a
// segment eighteen degrees round from the one below it has to STAY eighteen degrees round from it, or bending
// one segment of a curve flattens every segment above it onto its own angle.
readonly struct ArchStairChain
{
public List<StairTurn> Joints { get; init; }
public List<float> Bearings { get; init; }
public static ArchStairChain Of( ArchStairPart stair )
{
var chain = new ArchStairChain
{
Joints = new List<StairTurn> { StairTurn.None },
Bearings = new List<float> { stair.Lanes.Count > 0 ? stair.Lanes[0].Bearing : 0f }
};
for ( var index = 1; index < stair.Lanes.Count; index++ )
{
chain.Joints.Add( stair.Lanes[index - 1].Walk.Between( stair.Lanes[index].Walk ) );
chain.Bearings.Add( stair.Lanes[index].Bearing - stair.Lanes[index - 1].Bearing );
}
return chain;
}
// A step inserted into the chain carries straight on and takes no turn of its own until it is given one.
public void Insert( int index, int count = 1 )
{
Joints.InsertRange( index, Enumerable.Repeat( StairTurn.None, count ) );
Bearings.InsertRange( index, Enumerable.Repeat( 0f, count ) );
}
public float Bearing( ArchStairPart stair, int index )
{
return index < 1 ? Bearings[0] : stair.Lanes[index - 1].Bearing + Bearings[index];
}
}
// Every step above the one that moved, laid back onto the one below it, keeping the turn it made, the angle it
// made and the run and width it was drawn with.
static void Relay( ArchStairPart stair, int from, ArchStairChain chain )
{
for ( var index = Math.Max( 1, from ); index < stair.Lanes.Count; index++ )
{
var lane = stair.Lanes[index];
var below = stair.Lanes[index - 1];
Stand( lane, below, below.Walk.Turned( chain.Joints[index] ), lane.Length, lane.Width );
lane.Bearing = chain.Bearing( stair, index );
}
}
// A platform taken out of the step above it, which is where the space for one has to come from: the shaft is a
// frame the steps stand in, so growing the stair to make room would move every other step in it.
public static ArchStairLane AddLanding( ArchPlan plan, ArchStairPart stair, ArchStairLane after )
{
var index = IndexOf( stair, after );
if ( index < 0 )
{
return null;
}
var depth = MathF.Min( MathF.Max( ArchStairLanes.MinLane, after.Width ), after.Length * 0.4f );
if ( ArchStairWalk.Split( after, depth ) is not { } landing )
{
return null;
}
landing.Id = plan.AllocateId();
stair.Lanes.Insert( index + 1, landing );
return landing;
}
// Every step SLIDES, flight and platform alike, and it goes exactly where it is carried - the steps ABOVE follow
// it, and the first step therefore carries the whole climb. Where it JOINS the step below is not re-decided
// here: that is Join, and it waits for the drag to end.
public static void Move( ArchStairPart stair, ArchStairLane lane, Vector2 to )
{
var index = IndexOf( stair, lane );
if ( index < 0 )
{
return;
}
var chain = ArchStairChain.Of( stair );
// Read off the step as it STANDS, so a swept segment comes with you from where you took hold of it rather
// than jumping by however far its turned footprint sits off its four numbers.
ArchStairEdges.Shift( lane, to - lane.Frame.Flat( lane.Length * 0.5f, lane.Width * 0.5f ) );
Relay( stair, index + 1, chain );
}
// WHERE a step may be joined onto the one below it: the middle of each of that step's four sides, and - where a
// side has the room for more than one step to stand on it - an even share of that side apiece.
//
// Two stations on one side is what a SWITCHBACK is: the climb arrives at one of them and leaves from the other,
// side by side on the same broad landing. A side with room for one offers one, so the stations say what will
// actually fit rather than offering a joint that cannot be built.
public static IEnumerable<ArchStairStation> Stations( ArchStairLane below, float width )
{
var frame = below.Frame;
var span = MathF.Max( ArchStairLanes.MinLane, width );
foreach ( var station in Along( below.Width, span ) )
{
yield return new ArchStairStation( frame.Flat( below.Length, station ), below.Walk );
yield return new ArchStairStation( frame.Flat( 0f, station ), below.Walk.Turned( StairTurn.Back ) );
}
foreach ( var station in Along( below.Length, span ) )
{
yield return new ArchStairStation( frame.Flat( station, below.Width ), below.Walk.Turned( StairTurn.Left ) );
yield return new ArchStairStation( frame.Flat( station, 0f ), below.Walk.Turned( StairTurn.Right ) );
}
}
// How a side divides: one station per step that fits on it, each in the middle of its own share. Capped, because
// a very long flank would otherwise offer a row of stations nobody is choosing between.
static IEnumerable<float> Along( float side, float width )
{
var count = Math.Clamp( (int)MathF.Floor( side / width ), 1, MostStations );
for ( var station = 0; station < count; station++ )
{
yield return side * (station + 0.5f) / count;
}
}
public const int MostStations = 4;
// The joint the AUTHOR pointed at: the step is carried so the middle of its foot lands on the station picked on
// the step below, and NOTHING else moves - not its walk, not its bearing, not the run of the step it joins.
//
// A guess is what this replaced, and every guess was wrong in a way that cost work: read the joint off the drop
// and a flight carried across the page comes back pointing the way it started; close it by stretching the step
// below and a platform silently shrinks to reach a foot dropped behind its head. Where two steps meet is a
// decision, so it is pointed at.
public static bool JoinTo( ArchStairPart stair, ArchStairLane lane, ArchStairStation station )
{
var index = IndexOf( stair, lane );
if ( index < 1 )
{
return false;
}
var chain = ArchStairChain.Of( stair );
var below = stair.Lanes[index - 1];
var run = lane.Length;
var width = lane.Width;
// A station is on a SIDE, and a step joined to a side walks out of it - so the joint aims the step as well
// as placing it, and it lines up with the step it leaves. Read before the walk changes, because a step's run
// and its width swap over with the way it is walked.
var frame = new ArchStairAxes { Yaw = Cardinal( station.Walk ) + below.Bearing };
Lay( lane, station.At - frame.Across * (width * 0.5f), station.Walk, run, width, below.Bearing );
Relay( stair, index + 1, chain );
return true;
}
static float Cardinal( StairWalk walk ) => walk switch
{
StairWalk.Back => 180f,
StairWalk.Left => 90f,
StairWalk.Right => -90f,
_ => 0f
};
// The one pass every edit ends on: the step below run up to meet the edited step, and every step above laid back
// onto it keeping the turn it made. An edge dragged, a width pulled, a climb pinned - each of them moves where
// the steps around it have to stand, and a chain that keeps its old numbers is a chain with a hole in it.
public static void Settle( ArchStairPart stair, ArchStairLane lane )
{
var index = IndexOf( stair, lane );
if ( index < 0 )
{
return;
}
var chain = ArchStairChain.Of( stair );
if ( index > 0 )
{
Meet( stair.Lanes[index - 1], lane );
}
Relay( stair, index + 1, chain );
}
// One step turned off the cardinal its walk names, with the chain above it following round. This is the grip a
// curve is adjusted on once it has been cut: every segment carries its own share, and bending one bends
// everything standing on it.
public static void Bend( ArchStairPart stair, ArchStairLane lane, float bearing )
{
var chain = ArchStairChain.Of( stair );
lane.Bearing = bearing;
Relay( stair, IndexOf( stair, lane ) + 1, chain );
}
// The step below runs its head up to the moved step's foot, so nothing is left standing in the gap the move
// opened and neither ends up inside the other. Only where the two share a band ACROSS the walk: at a turn the
// step above stands beside that head rather than in front of it, and running the head at it would eat the
// corner the pair share.
static void Meet( ArchStairLane below, ArchStairLane above )
{
if ( below.Walk != above.Walk )
{
return;
}
ArchStairEdges.Lengthen( below, ArchStairEdges.Along( below, above.Seat ) );
}
// Deleting a step gives its rectangle back to the one it was taken out of, or a stair keeps a gap where the
// platform used to be and the flights either side of it stop short of each other.
public static bool Remove( ArchStairPart stair, ArchStairLane lane )
{
var index = IndexOf( stair, lane );
if ( index < 0 || stair.Lanes.Count <= 1 )
{
return false;
}
stair.Lanes.RemoveAt( index );
if ( index > 0 )
{
Absorb( stair.Lanes[index - 1], lane );
}
ArchStairLanes.Fit( stair.Core, stair.Lanes );
return true;
}
// The step below grows its head back over what the removed one held, but only where the two actually share a
// band - a platform at a turn is given up by two steps and belongs wholly to neither.
static void Absorb( ArchStairLane below, ArchStairLane gone )
{
switch ( below.Walk )
{
case StairWalk.Ahead when Overlaps( below.AcrossFrom, below.AcrossTo, gone.AcrossFrom, gone.AcrossTo ):
below.AlongTo = MathF.Max( below.AlongTo, gone.AlongTo );
break;
case StairWalk.Back when Overlaps( below.AcrossFrom, below.AcrossTo, gone.AcrossFrom, gone.AcrossTo ):
below.AlongFrom = MathF.Min( below.AlongFrom, gone.AlongFrom );
break;
case StairWalk.Left when Overlaps( below.AlongFrom, below.AlongTo, gone.AlongFrom, gone.AlongTo ):
below.AcrossTo = MathF.Max( below.AcrossTo, gone.AcrossTo );
break;
case StairWalk.Right when Overlaps( below.AlongFrom, below.AlongTo, gone.AlongFrom, gone.AlongTo ):
below.AcrossFrom = MathF.Min( below.AcrossFrom, gone.AcrossFrom );
break;
}
}
static bool Overlaps( float from, float to, float otherFrom, float otherTo )
{
return from < otherTo - 0.05f && otherFrom < to - 0.05f;
}
}