Editor-side utility that converts a sequence of click points into a stair geometry. It computes lane segments, turns, landings and the stair core (origin, yaw, length, width, rise) and provides helpers to extend or inspect existing stair parts.
namespace Sunless.Architecture;
// What a walk came to. One resolve, so the ghost following the cursor and the stair the last click builds are the
// same steps in the same shaft.
public sealed class ArchStairWalked
{
public ArchStairCore Core { get; init; }
public List<ArchStairLane> Lanes { get; init; } = new();
public bool Stands => Core is not null && Lanes.Count > 0;
}
// A stair is WALKED, the way a pipe run is routed: click the foot, then click each point it turns or arrives at.
// Every leg is a flight and every turn drops a platform in between them, so the PATTERN IS THE CLICKS - two points
// make a straight run, a corner makes a quarter turn, a leg doubling back makes a switchback - and there is no
// pattern to pick off a grid.
//
// Every leg after the first is read in the FIRST leg's frame and reduced to the nearest of its four directions,
// because a step is a rectangle in the shaft's frame and a turn is always a rectangle. That is the whole cost of
// the shaft model, and what it buys is that two steps either abut or they do not.
public static class ArchStairWalk
{
readonly record struct Box( float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo );
const float RightAngle = 90f;
public static ArchStairWalked Sketch( IReadOnlyList<Vector3> points, float width, float climb )
{
if ( points is not { Count: >= 2 } || !Aim( points, out var yaw ) )
{
return new ArchStairWalked();
}
var frame = new ArchStairAxes { Origin = Flat( points[0] ), Yaw = yaw };
var lanes = Steps( Local( frame, points ), Half( width ) );
if ( lanes.Count == 0 )
{
return new ArchStairWalked();
}
var core = new ArchStairCore { Yaw = yaw, Rise = MathF.Max( 1f, Climbed( points, climb ) ) };
Seat( core, frame, lanes, Extents( lanes ) );
return new ArchStairWalked { Core = core, Lanes = lanes };
}
// Where a walk may pick a standing stair up again, the way a pipe route clicks a node another run already
// owns: the head of the last step, at the height the climb arrives at.
public static bool Head( ArchStairPart stair, out Vector3 head )
{
head = default;
if ( stair?.Core is not { } core || stair.Lanes.Count == 0 )
{
return false;
}
var lane = stair.Lanes[^1];
var flat = lane.Axes( core ).Flat( lane.Length, lane.Width * 0.5f );
head = new Vector3( flat.x, flat.y, stair.BaseHeight + stair.TotalRise );
return true;
}
// Walking a stair that already stands: the new legs are read in ITS frame, so a flight added later turns
// against the one below it exactly as it would have during the first walk.
public static bool Extend( ArchStairPart stair, IReadOnlyList<Vector3> points, float width )
{
if ( stair?.Core is not { } core || points is not { Count: >= 2 } )
{
return false;
}
var frame = core.Axes;
var walked = Steps( Local( frame, points ), Half( width ) );
if ( walked.Count == 0 )
{
return false;
}
var standing = stair.Lanes.ToList();
var bounds = Extents( standing.Append( Whole( core ) ).Concat( walked ) );
// Only the JOIN is turned. The standing steps were retreated when they were walked, and retreating them
// a second time is a stair that shrinks every time another flight is added to it.
if ( standing.Count > 0 && Turn( standing[^1], walked[0] ) is { } landing )
{
standing.Add( landing );
}
standing.AddRange( walked );
Seat( core, frame, standing, bounds );
stair.Lanes.Clear();
stair.Lanes.AddRange( standing );
core.Rise = MathF.Max( core.Rise, points[^1].z - stair.BaseHeight );
return true;
}
// ---- The legs ----
static List<ArchStairLane> Steps( IReadOnlyList<Vector2> locals, float half )
{
var flights = Banded( locals, half );
var steps = new List<ArchStairLane>();
for ( var index = 0; index < flights.Count; index++ )
{
steps.Add( flights[index] );
if ( index + 1 < flights.Count && Turn( flights[index], flights[index + 1] ) is { } landing )
{
steps.Add( landing );
}
}
return steps;
}
static List<ArchStairLane> Banded( IReadOnlyList<Vector2> locals, float half )
{
var lanes = new List<ArchStairLane>();
for ( var index = 0; index + 1 < locals.Count; index++ )
{
if ( Leg( locals[index], locals[index + 1], half ) is { } lane )
{
lanes.Add( lane );
}
}
return lanes;
}
// The band is centred on the leg's HEAD, because that is the click the author aimed: a flight doubling back
// is drawn by clicking where its bottom step lands, and centring on the foot would put it back on the flight
// it came off rather than beside it.
static ArchStairLane Leg( Vector2 from, Vector2 to, float half )
{
var span = to - from;
if ( MathF.Abs( span.x ) < ArchStairLanes.MinLane && MathF.Abs( span.y ) < ArchStairLanes.MinLane )
{
return null;
}
if ( MathF.Abs( span.y ) > MathF.Abs( span.x ) )
{
return new ArchStairLane
{
AlongFrom = to.x - half,
AlongTo = to.x + half,
AcrossFrom = MathF.Min( from.y, to.y ),
AcrossTo = MathF.Max( from.y, to.y ),
Walk = span.y >= 0f ? StairWalk.Left : StairWalk.Right
};
}
return new ArchStairLane
{
AlongFrom = MathF.Min( from.x, to.x ),
AlongTo = MathF.Max( from.x, to.x ),
AcrossFrom = to.y - half,
AcrossTo = to.y + half,
Walk = span.x >= 0f ? StairWalk.Ahead : StairWalk.Back
};
}
// Two flights that turn give the corner up rather than running into each other, and the platform IS what they
// gave up. A quarter turn gives half the other's width apiece, which leaves the corner square; a flight
// doubling back gives a whole width, because two steps on one axis share no corner and the half-landing has
// to come out of the run. Two flights walking the same way are a continuation and give up nothing.
static ArchStairLane Turn( ArchStairLane lane, ArchStairLane next )
{
if ( lane.Walk == next.Walk )
{
return null;
}
var room = MathF.Min( lane.Length, next.Length ) * 0.4f;
var back = next.Walk == lane.Reversed;
var head = MathF.Max( 0f, MathF.Min( back ? MathF.Min( lane.Width, next.Width ) : next.Width * 0.5f, room ) );
var foot = MathF.Max( 0f, MathF.Min( back ? head : lane.Width * 0.5f, room ) );
if ( head < 0.05f && foot < 0.05f )
{
return null;
}
var before = Rect( lane );
var after = Rect( next );
Retreat( lane, head, true );
Retreat( next, foot, false );
var given = Union( Given( before, Rect( lane ) ), Given( after, Rect( next ) ) );
return new ArchStairLane
{
Step = StairStep.Landing,
AlongFrom = given.AlongFrom,
AlongTo = given.AlongTo,
AcrossFrom = given.AcrossFrom,
AcrossTo = given.AcrossTo,
Walk = lane.Walk
};
}
// Taking a platform out of a step that already stands: the step gives up depth at its head and the platform is
// exactly what it gave. The same move a turn makes on both of its flights, so a landing added later is the
// landing a turn would have stood - and the shaft and every other step are left where they were.
public static ArchStairLane Split( ArchStairLane lane, float depth )
{
var room = MathF.Min( depth, lane.Length - ArchStairLanes.MinLane );
if ( room < 0.05f )
{
return null;
}
var before = Rect( lane );
Retreat( lane, room, true );
var given = Given( before, Rect( lane ) );
return new ArchStairLane
{
Step = StairStep.Landing,
AlongFrom = given.AlongFrom,
AlongTo = given.AlongTo,
AcrossFrom = given.AcrossFrom,
AcrossTo = given.AcrossTo,
Walk = lane.Walk
};
}
static void Retreat( ArchStairLane lane, float depth, bool head )
{
if ( depth < 0.05f )
{
return;
}
switch ( lane.Walk )
{
case StairWalk.Ahead when head:
case StairWalk.Back when !head:
lane.AlongTo -= depth;
break;
case StairWalk.Back when head:
case StairWalk.Ahead when !head:
lane.AlongFrom += depth;
break;
case StairWalk.Left when head:
case StairWalk.Right when !head:
lane.AcrossTo -= depth;
break;
default:
lane.AcrossFrom += depth;
break;
}
}
// The strip a step gave up, which is its own rectangle less what it kept. Exactly one of the four numbers
// moved, so the strip is that number's span across the whole of the other axis.
static Box Given( Box raw, Box kept )
{
if ( kept.AlongTo < raw.AlongTo - 0.05f )
{
return raw with { AlongFrom = kept.AlongTo };
}
if ( kept.AlongFrom > raw.AlongFrom + 0.05f )
{
return raw with { AlongTo = kept.AlongFrom };
}
if ( kept.AcrossTo < raw.AcrossTo - 0.05f )
{
return raw with { AcrossFrom = kept.AcrossTo };
}
return raw with { AcrossTo = kept.AcrossFrom };
}
static Box Union( Box one, Box two ) => new(
MathF.Min( one.AlongFrom, two.AlongFrom ),
MathF.Max( one.AlongTo, two.AlongTo ),
MathF.Min( one.AcrossFrom, two.AcrossFrom ),
MathF.Max( one.AcrossTo, two.AcrossTo ) );
static Box Rect( ArchStairLane lane ) => new( lane.AlongFrom, lane.AlongTo, lane.AcrossFrom, lane.AcrossTo );
// ---- The shaft the steps bound ----
static void Seat( ArchStairCore core, ArchStairAxes frame, IReadOnlyList<ArchStairLane> lanes, Box bounds )
{
foreach ( var lane in lanes )
{
lane.AlongFrom -= bounds.AlongFrom;
lane.AlongTo -= bounds.AlongFrom;
lane.AcrossFrom -= bounds.AcrossFrom;
lane.AcrossTo -= bounds.AcrossFrom;
}
core.Origin = frame.Flat( bounds.AlongFrom, bounds.AcrossFrom );
core.Length = MathF.Max( ArchStairLanes.MinLane, bounds.AlongTo - bounds.AlongFrom );
core.Width = MathF.Max( ArchStairLanes.MinLane, bounds.AcrossTo - bounds.AcrossFrom );
}
// Measured BEFORE the turns retreat anything, so the shaft still reaches the station a half-landing was
// reserved out of - a core refitted to the steps alone leaves the platform hanging over its own wall.
static Box Extents( IEnumerable<ArchStairLane> lanes )
{
var alongFrom = float.MaxValue;
var alongTo = float.MinValue;
var acrossFrom = float.MaxValue;
var acrossTo = float.MinValue;
foreach ( var lane in lanes )
{
alongFrom = MathF.Min( alongFrom, lane.AlongFrom );
alongTo = MathF.Max( alongTo, lane.AlongTo );
acrossFrom = MathF.Min( acrossFrom, lane.AcrossFrom );
acrossTo = MathF.Max( acrossTo, lane.AcrossTo );
}
return new Box( alongFrom, alongTo, acrossFrom, acrossTo );
}
static ArchStairLane Whole( ArchStairCore core ) => new()
{
AlongFrom = 0f,
AlongTo = core.Length,
AcrossFrom = 0f,
AcrossTo = core.Width
};
// ---- Reading the clicks ----
// Square, not however the clicks happened to fall: two points on the grid are almost never exactly on an axis,
// and a shaft that keeps the couple of degrees between them draws its floor plan standing on a corner. A stair
// that really does stand at an angle is aimed on the flight afterwards, where the angle is a number.
static bool Aim( IReadOnlyList<Vector3> points, out float yaw )
{
yaw = 0f;
for ( var index = 0; index + 1 < points.Count; index++ )
{
var span = Flat( points[index + 1] ) - Flat( points[index] );
if ( span.Length < ArchStairLanes.MinLane )
{
continue;
}
yaw = ArchGridService.Snap( MathF.Atan2( span.y, span.x ).RadianToDegree(), RightAngle );
return true;
}
return false;
}
// The clicks land on real surfaces, so a walk that ended a storey up has already said how far it climbs. One
// that stayed on the floor it started on has not, and falls back to what the sidebar asked for.
static float Climbed( IReadOnlyList<Vector3> points, float climb )
{
var walked = points[^1].z - points[0].z;
return walked > ArchGridService.FinestSize ? walked : climb;
}
static List<Vector2> Local( ArchStairAxes frame, IReadOnlyList<Vector3> points )
{
var along = frame.Along;
var across = frame.Across;
return points.Select( point =>
{
var offset = Flat( point ) - frame.Origin;
return new Vector2( Vector2.Dot( offset, along ), Vector2.Dot( offset, across ) );
} ).ToList();
}
static Vector2 Flat( Vector3 point ) => new( point.x, point.y );
static float Half( float width ) => MathF.Max( ArchStairLanes.MinLane, width ) * 0.5f;
}