Editor helper that constructs and manages porch stair parts. It decides when a porch should get a flight of steps, seeds and builds an ArchStairPart (position, dimensions, lanes), computes gates/openings from existing flights, and regrades steps when the deck changes.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// A porch's flights, resolved in ONE place. The steps used to be synthesised inside the generator every
// build from a fraction and a width, which is why they had no row, no handles and no rail: nothing was
// ever filed. They are real ArchStairParts now, and this seeds them, keeps their climb honest, and reads
// the balustrade's gaps back off the flights actually standing - so a gap and a flight can never be two
// opinions about one gesture.
public static class ArchPorchSteps
{
const float LeastDrop = 4f;
const float LeastWidth = 24f;
const float WidestSeed = 60f;
// One way in, whether it is the flight a placement owes the deck or the third one an author asked for -
// each lands on the widest stretch nothing is already coming down through, and is then dragged like any
// other flight.
public static ArchStairPart Stand( ArchPlan plan, ArchPorchPart porch, ArchPorchShape shape, ArchKit kit )
{
if ( !shape.IsUsable || porch.DeckDrop < LeastDrop || !Fronting( shape, Gates( porch, shape ), out var landing ) )
{
return null;
}
var flight = Flight( plan, porch, shape, kit, landing, Seeded( porch ) );
porch.Stairs.Add( flight );
return flight;
}
// The affector's half of the contract: a deck raised, lowered or re-graded re-treads every flight it
// carries. Where a flight STANDS is the author's, so nothing here moves it along the perimeter.
public static void Settle( ArchPorchPart porch, ArchPorchShape shape, ArchKit kit )
{
var climb = MathF.Max( 1f, shape.Deck - shape.Grade );
var rise = MathF.Max( 3f, kit.StepRise );
var steps = Math.Max( 1, ArchDivide.AtMost( climb, rise ).Count );
foreach ( var flight in porch.Stairs )
{
flight.BaseHeight = shape.Grade;
flight.StepRise = climb / steps;
if ( flight.Core is { } core )
{
core.Rise = climb;
}
}
}
// Where the balustrade opens: read off the flights, never off a stored fraction. Deleting a flight
// closes its gap in the same breath, because there is nothing else saying the gap was ever there.
public static List<ArchBayGate> Gates( ArchPorchPart porch, ArchPorchShape shape )
{
var gates = new List<ArchBayGate>();
foreach ( var flight in porch.Stairs.Where( flight => flight.Core is not null ) )
{
if ( !ArchBays.Nearest( shape.Open, Head( flight ), out var landing ) )
{
continue;
}
var width = MathF.Max( LeastWidth, flight.Width );
gates.Add( new ArchBayGate { At = landing.Distance - width * 0.5f, Width = width } );
}
return gates;
}
// The top of the last riser, which is the point that lands on the deck edge.
public static Vector2 Head( ArchStairPart flight )
{
var core = flight.Core;
var lane = ArchStairShape.Standing( flight, core )[^1];
var axes = lane.Axes( core );
return axes.Flat( lane.Length, lane.Width * 0.5f );
}
static ArchStairPart Flight( ArchPlan plan, ArchPorchPart porch, ArchPorchShape shape, ArchKit kit, ArchStation landing, float width )
{
var going = MathF.Max( 6f, kit.StepGoing );
var rise = MathF.Max( 3f, kit.StepRise );
var steps = Math.Max( 1, ArchDivide.AtMost( porch.DeckDrop, rise ).Count );
var outward = landing.Outward;
var travel = outward * (steps * going);
var origin = landing.Point + travel + new Vector2( -outward.y, outward.x ) * (width * 0.5f);
var (core, lanes) = ArchStairCore.Straight( origin, -outward, steps * going, width, steps * rise );
return new ArchStairPart
{
Id = plan.AllocateId(),
Name = $"Steps{porch.Stairs.Count + 1}",
BaseHeight = shape.Grade,
Core = core,
Lanes = lanes,
Width = width,
StepRise = rise,
StepGoing = going,
TopLanding = false,
WellGuard = false,
Guard = StairGuard.None,
TreadThickness = 2.5f
};
}
// The middle of the widest open run nothing already comes down through - a lean-to porch's steps belong
// on the elevation it fronts, not halfway round a tape measure that starts wherever the loop happened to
// be wound from, and a second flight belongs somewhere the first one is not.
static bool Fronting( ArchPorchShape shape, IReadOnlyList<ArchBayGate> taken, out ArchStation landing )
{
var stations = new List<(float Length, float At)>();
var travelled = 0f;
foreach ( var run in shape.Open )
{
stations.Add( (run.Length, travelled + run.Length * 0.5f) );
travelled += run.Length;
}
var ordered = stations.OrderByDescending( entry => entry.Length ).ToList();
foreach ( var entry in ordered )
{
if ( !taken.Any( gate => gate.Spans( entry.At, entry.At ) ) && ArchBays.Station( shape.Open, entry.At, out landing ) )
{
return true;
}
}
// Every stretch is spoken for; the widest still takes it rather than the porch refusing outright.
landing = default;
return ordered.Count > 0 && ArchBays.Station( shape.Open, ordered[0].At, out landing );
}
static float Seeded( ArchPorchPart porch )
{
var span = porch.Legs.Count == 0
? WidestSeed
: porch.Legs.Max( leg => MathF.Max( leg.Max.x - leg.Min.x, leg.Max.y - leg.Min.y ) );
return MathF.Max( LeastWidth, MathF.Min( WidestSeed, span * 0.5f ) );
}
}