Editor/Stair/ArchStairVoid.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// A boolean cut through a flight — surviving edges become guarded borders. Not ArchStairCarve (steps from a platform).
public readonly struct ArchStairVoiding {
public ArchPlan Plan { get; init; }
public ArchKit Kit { get; init; }
public int Level { get; init; }
public int HostId { get; init; }
// Fast-path: skip the cut walk entirely when no voids exist.
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; }
}