Editor utility for stair voiding logic. It computes where stair runs and landings are cut away by ArchCut volumes, yields surviving spans, cells, borders, and tracks opened edges for guardrail decisions.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// What a dragged boolean leaves of a flight. The algebra is the one every other host uses - ArchCut says which
// volumes reach this band, scoped by group and by stack order like any other cut - and the one thing a stair
// needs on top of it is that the surviving edge is a NEW BORDER: an edge on a stair is a drop, so it wears the
// balustrade the flight's own open side wears. A lift shaft standing in the well between two flights is this and
// nothing else, which is why it is one dragged box rather than an assembly.
//
// Not to be confused with ArchStairCarve, which cuts steps OUT of a platform's concrete. That makes a stair;
// this takes one away.
public readonly struct ArchStairVoiding
{
public ArchPlan Plan { get; init; }
public ArchKit Kit { get; init; }
public int Level { get; init; }
public int HostId { get; init; }
// False for the vast majority of flights, and every ask walks every cut in the plan - so the whole pass is
// skipped rather than asked and found empty a hundred times per flight.
public bool Any { get; init; }
}
public static class ArchStairVoid
{
public static ArchStairVoiding Open( ArchPlan plan, ArchKit kit, ArchStairPart stair, ArchStairShape shape, int level )
{
var voiding = new ArchStairVoiding
{
Plan = plan,
Kit = kit,
Level = level,
HostId = stair?.Id ?? 0
};
if ( plan is null || stair is null || shape.Runs.Count == 0 )
{
return voiding;
}
var reaches = ArchCut
.Volumes( plan, level, kit, shape.BaseHeight, shape.TopHeight, stair.Id, ArchCutAffects.Stairs, stair.Id )
.Any();
return new ArchStairVoiding
{
Plan = plan,
Kit = kit,
Level = level,
HostId = stair.Id,
Any = reaches
};
}
// The across spans that survive at one station along a run. Always yields the whole span when nothing cuts
// here, so a caller loops the same way whether the flight is cut or not.
public static IEnumerable<(float From, float To)> Spans(
ArchStairVoiding voiding, ArchStairAxes axes, float along, float from, float to, float bottom, float top )
{
if ( !voiding.Any || to - from < 0.05f )
{
yield return (from, to);
yield break;
}
var near = axes.Flat( along, from );
var far = axes.Flat( along, to );
var reach = to - from;
foreach ( var span in ArchCut.Outside( voiding.Plan, voiding.Kit, voiding.Level, voiding.HostId,
near, far, bottom, top, ArchCutAffects.Stairs, voiding.HostId ) )
{
yield return (from + span.From * reach, from + span.To * reach);
}
}
// A landing is a plate rather than a strip, so it is clipped in BOTH directions and comes back as the cells
// that survive. Two one-dimensional clips crossed is exact for the rectangular shaft this is nearly always
// used for, and a cell whose middle is inside the cut is dropped, so an L-shaped bite comes out right too.
public static IEnumerable<(float AlongFrom, float AlongTo, float AcrossFrom, float AcrossTo)> Cells(
ArchStairVoiding voiding, ArchStairAxes axes,
float alongFrom, float alongTo, float acrossFrom, float acrossTo, float bottom, float top )
{
if ( !voiding.Any )
{
yield return (alongFrom, alongTo, acrossFrom, acrossTo);
yield break;
}
var across = Spans( voiding, axes, (alongFrom + alongTo) * 0.5f, acrossFrom, acrossTo, bottom, top ).ToList();
var along = Crossing( voiding, axes, alongFrom, alongTo, (acrossFrom + acrossTo) * 0.5f, bottom, top ).ToList();
foreach ( var lane in across )
{
foreach ( var band in along )
{
if ( band.To - band.From < 0.05f || lane.To - lane.From < 0.05f )
{
continue;
}
if ( Taken( voiding, axes, (band.From + band.To) * 0.5f, (lane.From + lane.To) * 0.5f, bottom, top ) )
{
continue;
}
yield return (band.From, band.To, lane.From, lane.To);
}
}
}
// The same question along the run rather than across it.
static IEnumerable<(float From, float To)> Crossing(
ArchStairVoiding voiding, ArchStairAxes axes, float from, float to, float across, float bottom, float top )
{
var near = axes.Flat( from, across );
var far = axes.Flat( to, across );
var reach = to - from;
if ( reach < 0.05f )
{
yield break;
}
foreach ( var span in ArchCut.Outside( voiding.Plan, voiding.Kit, voiding.Level, voiding.HostId,
near, far, bottom, top, ArchCutAffects.Stairs, voiding.HostId ) )
{
yield return (from + span.From * reach, from + span.To * reach);
}
}
static bool Taken( ArchStairVoiding voiding, ArchStairAxes axes, float along, float across, float bottom, float top )
{
var point = axes.Flat( along, across );
foreach ( var volume in ArchCut.Volumes( voiding.Plan, voiding.Level, voiding.Kit, bottom, top,
voiding.HostId, ArchCutAffects.Stairs, voiding.HostId ) )
{
if ( ArchFootprint.Contains( volume.Footprint, point ) )
{
return true;
}
}
return false;
}
// Every edge a cut opened up a run, as the STRETCH it actually opened: sampled per tread, because the
// spans are already quantised to the treads the bite falls across, and merged into one border while the
// edge holds its station. The run's own two sides are excluded - those are already guarded - and a cut
// authored with GuardsOpenedEdges off keeps its edges bare, which is what lets a split stair carry a
// pillar line instead of a rail.
public static IEnumerable<ArchStairBorder> Borders( ArchStairVoiding voiding, ArchStairRun run )
{
if ( !voiding.Any )
{
yield break;
}
var openers = Openers( voiding, run );
var tracked = new List<TrackedBorder>();
for ( var step = 0; step < run.Steps; step++ )
{
var along = (step + 0.5f) * run.Going;
var height = run.Rake( along );
foreach ( var live in tracked.Where( border => border.Open ) )
{
live.Continued = false;
}
foreach ( var span in Spans( voiding, run.Axes, along, 0f, run.Width, height - 1f, height + 1f ) )
{
Track( tracked, openers, run, step, along, height, span.From, 1f, span.From > 0.5f );
Track( tracked, openers, run, step, along, height, span.To, -1f, span.To < run.Width - 0.5f );
}
foreach ( var live in tracked.Where( border => border.Open && !border.Continued ) )
{
live.Open = false;
}
}
foreach ( var border in tracked )
{
yield return new ArchStairBorder { Lane = border.Lane, Inward = border.Inward, From = border.From, To = border.To };
}
}
static void Track( List<TrackedBorder> tracked, List<(ArchCutPart Cut, List<ArchCarveVolume> Volumes)> openers,
ArchStairRun run, int step, float along, float height, float lane, float inward, bool inside )
{
if ( !inside || !Guarded( openers, run.Axes, along, lane - inward, height ) )
{
return;
}
var reached = (step + 1) * run.Going;
var live = tracked.FirstOrDefault( border =>
border.Open && border.Inward == inward && MathF.Abs( border.Lane - lane ) < 0.75f );
if ( live is null )
{
tracked.Add( new TrackedBorder { Lane = lane, Inward = inward, From = step * run.Going, To = reached, Open = true, Continued = true } );
return;
}
live.To = reached;
live.Continued = true;
}
// The cut standing just beyond the edge is the one that opened it, and it says whether the drop is guarded.
static bool Guarded( List<(ArchCutPart Cut, List<ArchCarveVolume> Volumes)> openers, ArchStairAxes axes, float along, float across, float height )
{
var point = axes.Flat( along, across );
foreach ( var opener in openers )
{
if ( opener.Volumes.Any( volume => ArchFootprint.Contains( volume.Footprint, point )
&& ArchCut.Reaches( volume, height - 1f, height + 1f ) ) )
{
return opener.Cut.GuardsOpenedEdges;
}
}
return true;
}
static List<(ArchCutPart Cut, List<ArchCarveVolume> Volumes)> Openers( ArchStairVoiding voiding, ArchStairRun run )
{
return ArchCut.Over( voiding.Plan, voiding.Level, run.Loop(), voiding.HostId, ArchCutAffects.Stairs, voiding.HostId )
.Select( cut => (cut, ArchCut.Resolve( cut, voiding.Kit ).ToList()) )
.ToList();
}
sealed class TrackedBorder
{
public float Lane;
public float Inward;
public float From;
public float To;
public bool Open;
public bool Continued;
}
}
// One opened edge up a run: where it stands across the flight, which side survives, and the stretch it opened.
public readonly struct ArchStairBorder
{
public float Lane { get; init; }
// +1 when the surviving treads lie on the increasing-across side of the edge.
public float Inward { get; init; }
public float From { get; init; }
public float To { get; init; }
}