Editor/Stair/ArchStairRail.cs
using System;
using System.Linq;
using System.Collections.Generic;
using Sandbox;
namespace Sunless.Architecture;
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;
const float BalusterSection = 1.3f;
const float CapOversail = 0.9f;
const float CapDepth = 1.8f;
const float WellReturn = RailDepth * 1.5f;
const float WallRailGap = 1.6f;
const float WallBracketSpacing = 48f;
const float PostAngle = 60f;
static float Spacing( ArchStairPart stair, ArchKit kit ) {
return MathF.Max( 3f, stair.BalusterSpacing > 0.01f ? stair.BalusterSpacing : kit.StairBalusterSpacing );
}
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>>();
WallRails( canvas, stair, shape, kit, skin, height );
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 ) );
foreach ( var run in shape.Runs ) {
foreach ( var border in ArchStairVoid.Borders( voiding, run ) ) {
BorderWalk( walks, run, border, kit, height );
}
}
}
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 );
}
foreach ( var chain in chains ) {
Infill( canvas, stair, kit, skin, chain, posts );
}
}
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;
}
// Last flight stops short of the slab so the ring can turn onto it.
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;
}
// Intermediate station at the last nosing prevents the rail sagging off the treads mid-flight.
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;
}
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>();
}
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
};
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;
}
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;
}
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 );
}
}
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 );
}
}
}
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 = ArchLap.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 );
}
// Spacing is divided along the whole walk so balusters carry across stations.
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 = ArchLap.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 );
}
}
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 );
}
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);
}
// Posts only at bends sharper than PostAngle.
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;
}
// Deduplicates posts at shared stations across chains.
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 );
}
}
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 )
} );
}
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 = ArchLap.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 );
}
}
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 ) ) ) ) {
// Seam at rake-crossing stations so each segment is judged independently.
var loop = shape.Seamed( merged );
// CCW loop: void left, floor right — ring stands on the floor side, inset from the edge.
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;
}
// Union boolean cut holes that overlap the stairwell before walking the ring.
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;
}
}
}
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;
}
// Single-edge ends use their own normal, not the bisector.
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
};
}
static Vector2 Mitre( Vector2 before, Vector2 after ) {
var closing = 1f + Vector2.Dot( before, after );
return closing < 0.01f ? before : (before + after) / closing;
}
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 );
}
}
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 ) };
}
// Nearest walk end beside the ring's last edge — takes the closest to avoid crossing the mouth.
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;
}
static Vector2 Inward( Vector2 a, Vector2 b, float sign ) {
var unit = (b - a).Normal;
return new Vector2( -unit.y, unit.x ) * sign;
}
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;
}
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 );
}
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 );
}
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 );
}
}