Editor code that generates stair handrails, balusters and newels for architectural stairs. It computes guard walks, rings around wells, posts placement, baluster spacing and emits geometry (bars, blocks, prisms) into an ArchMesh using stair shape, kit and skin data.
using System;
using System.Linq;
using System.Collections.Generic;
using Sandbox;
namespace Sunless.Architecture;
// One station on a guard's walk: where the handrail stands in plan, the surface the guard is planted in there,
// and the height the handrail's underside rides at. The foot is what a baluster lands on - the string's own top
// face up a flight, the boards on a landing - so the balustrade is seated in the stair rather than beside it.
public readonly struct ArchStairGuardNode
{
public Vector2 At { get; init; }
public float Foot { get; init; }
public float Top { get; init; }
public StairRailing Railing { get; init; }
}
public static partial class ArchStairGen
{
const float RailDepth = 3f;
const float RailSection = 2.2f;
// A baluster is a stick under a rail, not a post beside one: slim enough that a run of them reads as infill.
const float BalusterSection = 1.3f;
const float CapOversail = 0.9f;
const float CapDepth = 1.8f;
// How far onto the floor the well ring stands off the hole's edge - the radius a handrail's 180 at the head
// of a flight turns through, and so the gap the level rail comes back across from the rake under it.
const float WellReturn = RailDepth * 1.5f;
// The bracket's reach: how far a wall-mounted handrail stands off the plaster.
const float WallRailGap = 1.6f;
const float WallBracketSpacing = 48f;
// A bend shallower than this is one the handrail is mitred round, not one a stair stands a post at.
const float PostAngle = 60f;
static float Spacing( ArchStairPart stair, ArchKit kit )
{
return MathF.Max( 3f, stair.BalusterSpacing > 0.01f ? stair.BalusterSpacing : kit.StairBalusterSpacing );
}
// The balustrade is two guards with one handover rule: the flight's OWN rail follows the walk up every
// flight and round every landing, and the well ring rails what is left of the hole's boundary. An edge the
// walk reaches is never the ring's, whichever of them stands higher - two rails up one line is a stairwell
// with two railings in it, which is not a thing a house has.
static void Balustrade( ArchMesh canvas, ArchStairPart stair, ArchStairShape shape, ArchKit kit, ArchStairSkin skin, ArchStairVoiding voiding, List<ArchStairGuardNode> posts, ArchPlan plan, ArchBuilding building )
{
var height = MathF.Max( 12f, kit.HandrailHeight );
var walks = new List<List<ArchStairGuardNode>>();
// The wall rail is what a walled side gets INSTEAD of a balustrade, so it stands whatever the guard says.
WallRails( canvas, stair, shape, kit, skin, height );
// None is the stair's DEFAULT, not a veto: a step carrying railing rows of its own has already overruled
// it, and returning here would make "no balustrade" mean "and none may be added either". With AutoRailings
// off the same holds - nothing is derived, but what was placed by hand still stands.
if ( (stair.Guard != StairGuard.None && stair.AutoRailings) || stair.Lanes.Any( lane => lane.Guards.Count > 0 ) )
{
walks.AddRange( Walks( stair, shape, kit, height, right: true ) );
walks.AddRange( Walks( stair, shape, kit, height, right: false ) );
// A boolean over the flight leaves an edge that is not either of its sides, and an edge on a stair is
// a drop. It gets the same rail the sides get - along the stretch the cut actually opened - so a shaft
// dropped into the well comes out guarded without a second gesture. The CUT says whether its edges are
// guarded, which is what lets a split stair carry a bare pillar line instead.
foreach ( var run in shape.Runs )
{
foreach ( var border in ArchStairVoid.Borders( voiding, run ) )
{
BorderWalk( walks, run, border, kit, height );
}
}
}
// Every walk is known before any of it is laid: a ring turns onto the END of one, so it cannot be built
// until they all exist. Laying them after keeps one newel per station however many chains meet there.
var chains = walks.Concat( WellRings( stair, shape, kit, height, walks, plan, building ) ).ToList();
foreach ( var chain in chains )
{
Handrail( canvas, chain, kit, skin );
Posting( chain, kit, posts );
}
// Infill last, once every newel on the stair is standing: a baluster is skipped where a post already fills
// the space, and a chain cannot know about the posts of the one that turns onto it until they are all up.
foreach ( var chain in chains )
{
Infill( canvas, stair, kit, skin, chain, posts );
}
}
// The guard as it is walked rather than as it is assembled: one unbroken chain of stations per side, foot
// to head, through every flight and round every landing between them. A house stair is one flow of railing
// that never ends, so the chain is the thing that is built and the rails, balusters and posts are laid along
// it — not five emitters each posting and barring where they happen to stop.
//
// A chain breaks only where the guard genuinely stops: a walled flight, a side left bare, a railing dragged
// to part of a flight, or a flight that runs on past a floor and buries its rail in the slab.
static List<List<ArchStairGuardNode>> Walks( ArchStairPart stair, ArchStairShape shape, ArchKit kit, float height, bool right )
{
var walks = new List<List<ArchStairGuardNode>>();
var walk = new List<ArchStairGuardNode>();
for ( var index = 0; index < shape.Runs.Count; index++ )
{
var run = shape.Runs[index];
if ( !Guarded( run, right ) )
{
walk = Break( walks, walk );
continue;
}
var lane = Lane( run, right );
// A railing dragged to part of a flight comes out over that part. Nothing authored spans 0 to 1, so
// this is a no-op for every stair that never touched one.
var span = right ? run.SpanRight : run.SpanLeft;
var from = MathF.Max( 0f, span.x * run.Length );
var to = MathF.Min( Reaches( shape, stair, run, height ), span.y * run.Length );
if ( to - from < 1f )
{
walk = Break( walks, walk );
continue;
}
if ( from > 0.5f )
{
walk = Break( walks, walk );
}
Climbing( walk, run, lane, from, to, height, right ? run.RailingRight : run.RailingLeft );
// A landing standing on a floor does NOT end the walk. The rail turns onto it and carries round its
// railed edges, which is the quarter turn at the head of a flight - the rake arriving, the newel, and
// the run along the storey above leaving it. Handing the landing to the well ring instead is what
// stopped the rail dead at the top; the ring knows to leave those edges alone.
var pad = to > run.Length - 0.5f ? PadBetween( shape, run ) : null;
if ( pad is null )
{
walk = Break( walks, walk );
continue;
}
var above = index + 1 < shape.Runs.Count ? shape.Runs[index + 1] : null;
var carries = above is not null && Guarded( above, right );
if ( !Across( pad, walk[^1].At, carries ? above.Axes.Flat( 0f, Lane( above, right ) ) : null, walk, height ) )
{
walk = Break( walks, walk );
}
}
Break( walks, walk );
return walks;
}
// How far up its flight a rail actually gets. All the way, for every flight that ARRIVES somewhere - the head
// is a landing and the walk turns onto it. Only a climb carrying on past a floor has a slab to run into, and
// there the rail stops a well gap short of it, which is the length a handrail needs to turn through.
static float Reaches( ArchStairShape shape, ArchStairPart stair, ArchStairRun run, float height )
{
if ( !run.Last )
{
return run.Length;
}
foreach ( var well in shape.Levels )
{
if ( well.Height > run.TopHeight + 1f )
{
return run.Under( height ) - MathF.Max( 0f, stair.WellGap );
}
}
return run.Length;
}
// The stations one flight puts on the walk: its two ends, and the last nosing between them, where the rake
// levels off onto the landing it arrives at. Two stations alone draw a chord that sags half a riser clear of
// the treads by the middle of the run, which is how a rail comes to read as a thing hung beside the stair.
static void Climbing( List<ArchStairGuardNode> walk, ArchStairRun run, float lane, float from, float to, float height, StairRailing railing )
{
var levels = run.Length - run.Going;
walk.Add( Station( run, lane, from, height, railing ) );
if ( levels > from + 0.5f && levels < to - 0.5f )
{
walk.Add( Station( run, lane, levels, height, railing ) );
}
walk.Add( Station( run, lane, to, height, railing ) );
}
static ArchStairGuardNode Station( ArchStairRun run, float lane, float along, float height, StairRailing railing )
{
return new ArchStairGuardNode
{
At = run.Axes.Flat( along, lane ),
Foot = run.Rake( along ),
Top = run.Nosing( along ) + height,
Railing = railing
};
}
static bool Guarded( ArchStairRun run, bool right )
{
return right ? run.GuardRight && !run.WalledRight : run.GuardLeft && !run.WalledLeft;
}
// Inboard of the guarded flank by half the rail's section, which lands it down the middle of the string -
// the plank a baluster is actually mortised into, rather than out over the drop beside it.
static float Lane( ArchStairRun run, bool right )
{
return right ? Centre( 0f, run.Width ) : Centre( run.Width, 0f );
}
static List<ArchStairGuardNode> Break( List<List<ArchStairGuardNode>> walks, List<ArchStairGuardNode> walk )
{
if ( walk.Count > 1 )
{
walks.Add( walk );
}
return new List<ArchStairGuardNode>();
}
// Across the landing the way the walk actually goes round it: along the landing's OWN railable edges, from
// where the flight below's rail arrives to where the flight above's picks up. The mouth the walk leaves
// through is never one of them, which is what makes a rail standing across the walk unreachable rather
// than merely unwanted. A quarter turn wraps the outer flank into the head and the inner one turns on the
// spot; a switchback wraps flank, head, flank on the outside and crosses the well gap on the inside.
static bool Across( ArchStairPad pad, Vector2 entry, Vector2? exit, List<ArchStairGuardNode> walk, float height )
{
var inset = RailDepth * 0.5f;
var corners = new List<Vector2>
{
pad.Axes.Flat( pad.AlongFrom, pad.AcrossFrom + inset ),
pad.Axes.Flat( pad.AlongTo - inset, pad.AcrossFrom + inset ),
pad.Axes.Flat( pad.AlongTo - inset, pad.AcrossTo - inset ),
pad.Axes.Flat( pad.AlongFrom, pad.AcrossTo - inset )
};
var rails = new[] { pad.RailsFrom, pad.RailsHead, pad.RailsTo };
var start = Nearest( corners, entry );
var carried = walk.Count > 0 ? walk[^1].Railing : StairRailing.Balustrade;
ArchStairGuardNode Corner( Vector2 at ) => new()
{
At = at,
Foot = pad.Height,
Top = pad.Height + height,
Railing = carried
};
// Neither end stands on the landing's boundary: this is the head of a switchback's well gap, and the
// walk crosses it rather than going round something that is not in the way.
if ( start < 0 )
{
return exit is not null;
}
if ( exit is not null )
{
var end = Nearest( corners, exit.Value );
// The walk picks up somewhere the landing's boundary does not reach: it crosses to it directly,
// which is the inside of a switchback and nothing else.
if ( end < 0 || end == start )
{
return true;
}
var toward = end > start ? 1 : -1;
for ( var index = start; index != end; index += toward )
{
if ( !rails[toward > 0 ? index : index - 1] )
{
return false;
}
}
for ( var index = start + toward; index != end; index += toward )
{
walk.Add( Corner( corners[index] ) );
}
return true;
}
// Nothing picks the walk up above: it runs on over every edge the landing owns and stops at the last.
var away = start < 2 ? 1 : -1;
for ( var index = start; index + away >= 0 && index + away < corners.Count; index += away )
{
if ( !rails[away > 0 ? index : index - 1] )
{
break;
}
walk.Add( Corner( corners[index + away] ) );
}
return false;
}
// Which corner of the landing a rail end stands at, or none where it stands out in the middle of one.
static int Nearest( IReadOnlyList<Vector2> corners, Vector2 point )
{
var nearest = -1;
var closest = RailDepth * 1.5f;
for ( var index = 0; index < corners.Count; index++ )
{
var reach = (corners[index] - point).Length;
if ( reach < closest )
{
closest = reach;
nearest = index;
}
}
return nearest;
}
// The handrail over every stretch of one walk.
static void Handrail( ArchMesh canvas, IReadOnlyList<ArchStairGuardNode> walk, ArchKit kit, ArchStairSkin skin )
{
for ( var index = 0; index + 1 < walk.Count; index++ )
{
Stretch( canvas, walk[index], walk[index + 1], kit, skin );
}
}
// A newel where the walk ends, and one wherever it turns.
static void Posting( IReadOnlyList<ArchStairGuardNode> walk, ArchKit kit, List<ArchStairGuardNode> posts )
{
if ( walk.Count < 2 )
{
return;
}
Post( walk[0], kit, posts );
Post( walk[^1], kit, posts );
for ( var index = 1; index + 1 < walk.Count; index++ )
{
if ( Turns( walk, index ) )
{
Post( walk[index], kit, posts );
}
}
}
// The handrail between two stations, lapped a bite past both so the kink at a landing closes instead of
// opening a wedge.
static void Stretch( ArchMesh canvas, ArchStairGuardNode a, ArchStairGuardNode b, ArchKit kit, ArchStairSkin skin )
{
var reach = (b.At - a.At).Length;
if ( reach < 1f )
{
return;
}
var bite = ArchContact.Bite( kit );
var unit = (b.At - a.At) / reach;
var axes = new ArchStairAxes { Origin = a.At, Yaw = MathF.Atan2( unit.y, unit.x ).RadianToDegree() };
var capping = (b.Top - a.Top) / reach;
Bar( canvas, axes, -bite, reach + bite, 0f, a.Top - capping * bite, b.Top + capping * bite, skin.Rail );
}
// The balusters, divided along the WHOLE walk rather than restarted at every station. Dividing each stretch on
// its own dropped both its ends, so every station stood in a bare band and a stretch shorter than the spacing -
// the 180 at the head of a flight is six inches long - carried none at all. They land on the foot line rather
// than on a bottom rail of their own: a balustrade that touches the string is part of the stair, and one
// hovering over it is a fence somebody left beside one.
static void Infill( ArchMesh canvas, ArchStairPart stair, ArchKit kit, ArchStairSkin skin, IReadOnlyList<ArchStairGuardNode> walk, IReadOnlyList<ArchStairGuardNode> posts )
{
var spacing = Spacing( stair, kit );
var clear = MathF.Max( 1f, kit.NewelSize ) * 0.5f + BalusterSection;
var bite = ArchContact.Bite( kit );
var total = 0f;
for ( var index = 0; index + 1 < walk.Count; index++ )
{
total += (walk[index + 1].At - walk[index].At).Length;
}
if ( total < 1f )
{
return;
}
foreach ( var offset in ArchDivide.AtLeast( total, spacing ).Inner )
{
Stands( canvas, walk, offset, clear, bite, posts, skin );
}
}
// One baluster at a distance measured along the whole walk: which stretch that lands on is worked back from the
// distance, so the run reads as one comb rather than as a comb per stretch.
static void Stands( ArchMesh canvas, IReadOnlyList<ArchStairGuardNode> walk, float offset, float clear, float bite, IReadOnlyList<ArchStairGuardNode> posts, ArchStairSkin skin )
{
var (index, along) = Seat( walk, offset );
if ( index < 0 )
{
return;
}
var a = walk[index];
var b = walk[index + 1];
if ( a.Railing != StairRailing.Balustrade )
{
return;
}
var reach = (b.At - a.At).Length;
var unit = (b.At - a.At) / reach;
var at = a.At + unit * along;
// A newel already fills that space, and a baluster inside one is a face nobody can see.
if ( posts.Any( post => (post.At - at).Length < clear ) )
{
return;
}
var seating = (b.Foot - a.Foot) / reach;
var capping = (b.Top - a.Top) / reach;
var axes = new ArchStairAxes { Origin = at, Yaw = MathF.Atan2( unit.y, unit.x ).RadianToDegree() };
Baluster( canvas, axes, 0f, a.Foot + seating * along - bite, a.Top + capping * along + bite, skin, seating, capping );
}
// Where a distance measured along the WHOLE walk lands: the stretch it falls on and how far into that stretch.
// This is what carries the run of balusters ACROSS a station instead of restarting it there.
internal static (int Index, float Along) Seat( IReadOnlyList<ArchStairGuardNode> walk, float offset )
{
var along = offset;
for ( var index = 0; index + 1 < walk.Count; index++ )
{
var reach = (walk[index + 1].At - walk[index].At).Length;
if ( reach < 0.01f )
{
continue;
}
if ( along > reach )
{
along -= reach;
continue;
}
return (index, along);
}
return (-1, 0f);
}
// Where a stair stands a post: the rake meeting the level it turns onto, and a SQUARE corner in plan. A bend
// shallower than PostAngle is one the handrail is simply mitred round, and posting at each of those crowded a
// landing with newels a house does not have.
static bool Turns( IReadOnlyList<ArchStairGuardNode> walk, int index )
{
var before = walk[index].At - walk[index - 1].At;
var after = walk[index + 1].At - walk[index].At;
if ( before.Length < 0.5f || after.Length < 0.5f )
{
return true;
}
if ( Vector2.Dot( before.Normal, after.Normal ) < MathF.Cos( PostAngle.DegreeToRadian() ) )
{
return true;
}
var rising = (walk[index].Top - walk[index - 1].Top) / before.Length;
var leaving = (walk[index + 1].Top - walk[index].Top) / after.Length;
return MathF.Abs( leaving - rising ) > 0.05f;
}
// One newel per station, however many chains meet there: two of them handing over share that station, so
// collecting per chain stood the same post twice.
static void Post( ArchStairGuardNode node, ArchKit kit, List<ArchStairGuardNode> posts )
{
if ( posts.Any( post => (post.At - node.At).Length < MathF.Max( 1f, kit.NewelSize ) ) )
{
return;
}
posts.Add( node );
}
static void Newels( ArchMesh canvas, ArchKit kit, ArchStairSkin skin, IReadOnlyList<ArchStairGuardNode> posts )
{
foreach ( var post in posts )
{
Newel( canvas, new ArchStairAxes { Origin = post.At }, 0f, 0f, post.Foot, post.Top - post.Foot, kit, skin );
}
}
// The rail up an edge a cut opened: a post at each end of the opened stretch, the rake between them, and
// the infill the flight wears - the same emitters its own sides use, standing at the station and along the
// extent the boolean left. It faces INTO the surviving treads, which is the side someone can stand on.
static void BorderWalk( List<List<ArchStairGuardNode>> walks, ArchStairRun run, ArchStairBorder border, ArchKit kit, float height )
{
var post = MathF.Max( 1f, kit.NewelSize ) * 0.5f;
var lane = border.Lane + border.Inward * RailDepth * 0.5f;
var foot = MathF.Max( 0f, border.From ) + post;
var head = MathF.Min( run.Length, border.To ) - post;
if ( head - foot < 1f )
{
return;
}
walks.Add( new List<ArchStairGuardNode>
{
Station( run, lane, foot, height, StairRailing.Balustrade ),
Station( run, lane, head, height, StairRailing.Balustrade )
} );
}
// The handrail on the plaster side: the rake on brackets, no posts and no infill - the rail a walled
// flight actually gets in a house or a stairwell core.
static void WallRails( ArchMesh canvas, ArchStairPart stair, ArchStairShape shape, ArchKit kit, ArchStairSkin skin, float height )
{
foreach ( var run in shape.Runs )
{
if ( run.HandrailRight )
{
WallRail( canvas, run, WallRailGap + RailDepth * 0.5f, kit, skin, height );
}
if ( run.HandrailLeft )
{
WallRail( canvas, run, run.Width - WallRailGap - RailDepth * 0.5f, kit, skin, height );
}
}
}
static void WallRail( ArchMesh canvas, ArchStairRun run, float lane, ArchKit kit, ArchStairSkin skin, float height )
{
var from = 0f;
var to = run.Last ? run.Under( height ) : run.Length;
if ( to - from < 12f )
{
return;
}
Bar( canvas, run.Axes, from, to, lane, run.Nosing( from ) + height, run.Nosing( to ) + height, skin.Rail );
var bite = ArchContact.Bite( kit );
var wall = lane < run.Width * 0.5f ? -bite : run.Width + bite;
foreach ( var offset in ArchDivide.AtLeast( to - from, WallBracketSpacing ).Inner.DefaultIfEmpty( (to - from) * 0.5f ) )
{
var along = from + offset;
var seat = run.Nosing( along ) + height;
Block( canvas, run.Axes, along - 0.8f, along + 0.8f,
MathF.Min( wall, lane ), MathF.Max( wall, lane ), seat - 1.6f, seat + bite, skin.Rail );
}
}
// It rings the boundary of each floor's hole, and it is walked, not assembled: one chain per unbroken run of
// guarded edges, taking in the free end of every walk that climbs to it. That is what makes the guard one rail
// from the foot of a flight, up the rake, round the 180 and back down the far side — rather than a ring
// standing beside a balustrade that stops next to it.
static List<List<ArchStairGuardNode>> WellRings( ArchStairPart stair, ArchStairShape shape, ArchKit kit, float height, IReadOnlyList<List<ArchStairGuardNode>> walks, ArchPlan plan, ArchBuilding building )
{
var rings = new List<List<ArchStairGuardNode>>();
if ( !stair.WellGuard )
{
return rings;
}
foreach ( var well in shape.Levels )
{
foreach ( var merged in ArchFootprint.Union( well.Loops.Concat( Opened( plan, building, kit, shape, well ) ) ) )
{
// Seamed first: an edge part flight, part landing has to be judged in pieces, and the
// rake-crossing station splits a flank where the guard changes hands.
var loop = shape.Seamed( merged );
// Counter-clockwise loop: void left, floor right. The ring stands ON the floor, a return's
// margin back from the hole's edge, which is where a landing balustrade is actually bolted.
// Out over the void it shared the edge line with the rake climbing under it and the two came
// up as one railing through another; back here the level rail returns BESIDE that rake, across
// the gap the handrail's 180 at the head of the flight turns through.
var standing = ArchFootprint.FloorSide( loop );
var guarded = shape.GuardedEdges( loop, well.Probe, kit, well.Height );
foreach ( var ring in Rings( loop, guarded, standing, well.Height, height ) )
{
Turning( ring, walks );
rings.Add( ring );
}
}
}
return rings;
}
// The holes a boolean opened in this floor that run into the stair's own well. Unioned in BEFORE the ring is
// walked, so the guard follows the boundary of the whole opening rather than the shape the flight alone made -
// and every rule the ring already keeps applies to the new edges with nothing written for them. A hole standing
// somewhere else on the storey is not this stair's to rail.
static IEnumerable<List<Vector2>> Opened( ArchPlan plan, ArchBuilding building, ArchKit kit, ArchStairShape shape, ArchStairWell well )
{
if ( plan is null || building is null )
{
yield break;
}
// The landings standing ON this floor FILL what a cut took out under them, and a floored square is not a
// drop. Without this the ring rails the cut's raw edge straight across the platform you walk off onto.
var floored = shape.Pads
.Where( pad => MathF.Abs( pad.Height - well.Height ) < 1f )
.Select( pad => (IReadOnlyList<Vector2>)pad.Loop() )
.ToList();
foreach ( var loop in ArchCut.Guarded( plan, well.Level, kit, well.Height - kit.FloorThickness, well.Height, building.Id ) )
{
if ( !well.Loops.Any( own => ArchFootprint.Overlaps( own, loop ) ) )
{
continue;
}
foreach ( var open in ArchFootprint.Subtract( new[] { loop }, floored ) )
{
yield return open;
}
}
}
// One chain per unbroken run of guarded edges round the loop, vertex to vertex.
static List<List<ArchStairGuardNode>> Rings( IReadOnlyList<Vector2> loop, IReadOnlyList<bool> guarded, float standing, float floor, float height )
{
var rings = new List<List<ArchStairGuardNode>>();
if ( !guarded.Any( edge => edge ) )
{
return rings;
}
// Nothing open anywhere round it: the ring closes on itself, so it is walked from any vertex back to it.
if ( guarded.All( edge => edge ) )
{
var closed = new List<ArchStairGuardNode>();
for ( var index = 0; index <= loop.Count; index++ )
{
closed.Add( Standing( loop, index % loop.Count, true, true, standing, floor, height ) );
}
rings.Add( closed );
return rings;
}
for ( var index = 0; index < loop.Count; index++ )
{
if ( !guarded[index] || guarded[(index + loop.Count - 1) % loop.Count] )
{
continue;
}
var ring = new List<ArchStairGuardNode> { Standing( loop, index, false, true, standing, floor, height ) };
var edge = index;
while ( guarded[edge] )
{
var next = (edge + 1) % loop.Count;
ring.Add( Standing( loop, next, true, guarded[next], standing, floor, height ) );
edge = next;
}
rings.Add( ring );
}
return rings;
}
// A vertex of the ring, set in onto the floor. An end the ring only arrives at takes that one edge's own
// normal — averaging in an edge nothing stands on would swing the end off the line it has to meet.
static ArchStairGuardNode Standing( IReadOnlyList<Vector2> loop, int index, bool arriving, bool leaving, float standing, float floor, float height )
{
var before = Inward( loop[(index + loop.Count - 1) % loop.Count], loop[index], standing );
var after = Inward( loop[index], loop[(index + 1) % loop.Count], standing );
return new ArchStairGuardNode
{
At = loop[index] + (arriving && leaving ? Mitre( before, after ) : arriving ? before : after) * WellReturn,
Foot = floor,
Top = floor + height,
Railing = StairRailing.Balustrade
};
}
// The corner offset that keeps BOTH edges' rails a full margin off their own line — a plain bisector pulls a
// square corner in short of it and the two stretches meet inside the turn.
static Vector2 Mitre( Vector2 before, Vector2 after )
{
var closing = 1f + Vector2.Dot( before, after );
return closing < 0.01f ? before : (before + after) / closing;
}
// The 180 at the head of a flight: the ring runs on down its OWN line to the point square with the rail it
// turns onto, stands its newel there, and crosses at a right angle. Turning for the walk's end from wherever
// the loop happened to stop left the post out in the middle of the landing and the two rails meeting on a
// diagonal, when what a stair has is two posts a handrail's width apart.
static void Turning( List<ArchStairGuardNode> ring, IReadOnlyList<List<ArchStairGuardNode>> walks )
{
if ( ring.Count > 1 && Ending( walks, ring[0], ring[1] ) is { } foot )
{
ring.Insert( 0, Square( ring[0], ring[1], foot ) );
ring.Insert( 0, foot );
}
if ( ring.Count > 1 && Ending( walks, ring[^1], ring[^2] ) is { } head )
{
ring.Add( Square( ring[^1], ring[^2], head ) );
ring.Add( head );
}
}
// The station on the ring's last edge that the rail it turns onto stands square of.
internal static ArchStairGuardNode Square( ArchStairGuardNode end, ArchStairGuardNode inner, ArchStairGuardNode target )
{
var span = end.At - inner.At;
if ( span.Length < 0.5f )
{
return end;
}
var unit = span.Normal;
return end with { At = inner.At + unit * Vector2.Dot( target.At - inner.At, unit ) };
}
// The free END of a walk the ring turns onto. Standing BESIDE the ring's last line is what makes an end
// eligible - a landing is however deep it is, so a reach limit along that line would miss the rail it turns
// onto by the whole depth of one - and of the ends that qualify the ring takes the NEAREST. Both flanks of an
// arrival stand square of the same line a landing's width apart, and taking whichever was found first ran the
// turn clean across the mouth to reach the far one, walling the stair off at the top.
static ArchStairGuardNode? Ending( IReadOnlyList<List<ArchStairGuardNode>> walks, ArchStairGuardNode end, ArchStairGuardNode inner )
{
var span = end.At - inner.At;
if ( span.Length < 0.5f )
{
return null;
}
var unit = span.Normal;
var across = new Vector2( -unit.y, unit.x );
var closest = float.MaxValue;
var nearest = (ArchStairGuardNode?)null;
foreach ( var walk in walks )
{
foreach ( var node in new[] { walk[0], walk[^1] } )
{
var offset = node.At - inner.At;
// Ahead of the ring, never behind it: a walk end back down the chain is one the ring already
// stands beside, not one it turns onto.
if ( MathF.Abs( node.Top - end.Top ) > 1f || Vector2.Dot( offset, unit ) < 0f )
{
continue;
}
if ( MathF.Abs( Vector2.Dot( offset, across ) ) >= WellReturn * 3f )
{
continue;
}
var reach = (node.At - end.At).Length;
if ( reach >= closest )
{
continue;
}
closest = reach;
nearest = node;
}
}
return nearest;
}
// The side of an edge a railing stands on.
static Vector2 Inward( Vector2 a, Vector2 b, float sign )
{
var unit = (b - a).Normal;
return new Vector2( -unit.y, unit.x ) * sign;
}
// The landing that hands a run to the next: found by where it sits, not by list order.
static ArchStairPad PadBetween( ArchStairShape shape, ArchStairRun run )
{
foreach ( var pad in shape.Pads )
{
if ( MathF.Abs( pad.AlongFrom - run.Length ) < 2f && MathF.Abs( pad.Height - run.TopHeight ) < 2f )
{
return pad;
}
}
return null;
}
// Inboard of the guarded edge by half its section, over the tread not the side.
static float Centre( float lane, float opposite ) => lane + MathF.Sign( opposite - lane ) * RailDepth * 0.5f;
static void Bar( ArchMesh canvas, ArchStairAxes axes, float from, float to, float lane, float low, float high, ArchBrush brush )
{
if ( to - from < 1f )
{
return;
}
var half = RailDepth * 0.5f;
var lower = new List<Vector3>
{
axes.Point( from, lane - half, low ),
axes.Point( to, lane - half, high ),
axes.Point( to, lane + half, high ),
axes.Point( from, lane + half, low )
};
var upper = new List<Vector3>();
foreach ( var point in lower )
{
upper.Add( point.WithZ( point.z + RailSection ) );
}
canvas.Prism( lower, upper, brush );
}
// A post is a shaft with a proud cap, the way a stair post is actually turned out of stock.
static void Newel( ArchMesh canvas, ArchStairAxes axes, float along, float lane, float floor, float height, ArchKit kit, ArchStairSkin skin )
{
var half = MathF.Max( 1f, kit.NewelSize ) * 0.5f;
var top = floor + height + MathF.Max( 0f, kit.NewelRise );
Block( canvas, axes, along - half, along + half, lane - half, lane + half, floor - RailSection, top, skin.Rail );
Block( canvas, axes, along - half - CapOversail, along + half + CapOversail,
lane - half - CapOversail, lane + half + CapOversail, top, top + CapDepth, skin.Rail );
}
// End faces tilt to the string's and the handrail's planes — left square they open a wedge.
static void Baluster( ArchMesh canvas, ArchStairAxes axes, float along, float bottom, float top, ArchStairSkin skin, float seatSlope, float headSlope )
{
if ( top - bottom < 4f )
{
return;
}
var half = BalusterSection * 0.5f;
var lower = new List<Vector3>
{
axes.Point( along - half, -half, bottom - seatSlope * half ),
axes.Point( along + half, -half, bottom + seatSlope * half ),
axes.Point( along + half, half, bottom + seatSlope * half ),
axes.Point( along - half, half, bottom - seatSlope * half )
};
var upper = new List<Vector3>
{
axes.Point( along - half, -half, top - headSlope * half ),
axes.Point( along + half, -half, top + headSlope * half ),
axes.Point( along + half, half, top + headSlope * half ),
axes.Point( along - half, half, top - headSlope * half )
};
canvas.Prism( lower, upper, skin.Rail );
}
}