Editor/Stair/ArchStairWalk.cs
namespace Sunless.Architecture;
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;
}
// Legs snap to the first leg's 4-direction frame so steps always abut cleanly
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 };
}
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;
}
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 — standing steps are already retreated
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;
}
// Centred on the head (the aimed click), not the foot
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
};
}
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
};
}
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;
}
}
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 retreats so the shaft includes half-landing stations
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 ----
// Snapped to nearest 90 — a stair with a deliberate angle is aimed after placement
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;
}
// Falls back to sidebar value when the walk stayed on one level
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;
}