Editor utility for architectural cuts. Computes carved volumes, floors, reaching logic, runs and path trimming for cuts (steps, ramps, loops), and provides helpers to sketch, extend, shift, rotate and relate cut segments within a plan.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// One resolution for ghost, generator and report; a stepped cut's band travels its whole chain.
public static class ArchCut
{
public const float MinRun = 18f;
public static Vector2 WorldBand( ArchCutSegment segment, float lift )
{
return new Vector2( segment.BaseHeight + lift, segment.TopHeight + lift );
}
// Held for the build: every host a shaft reaches asked for the same volumes, and each ask walked the whole
// chain and built a footprint per step.
public static IReadOnlyList<ArchCarveVolume> Resolve( ArchCutPart cut, ArchKit kit )
{
return ArchBuildMemo.Held( memo => memo.Volumes, cut?.Id ?? 0, () => Resolved( cut, kit ).ToList() );
}
static IEnumerable<ArchCarveVolume> Resolved( ArchCutPart cut, ArchKit kit )
{
if ( cut is not { HasContent: true } )
{
yield break;
}
if ( cut.IsDamage )
{
foreach ( var volume in ArchDamage.Resolve( cut, kit ) )
{
yield return volume;
}
yield break;
}
var legs = cut.Segments.Where( segment => segment.HasLoop || segment.Length > 1f ).ToList();
var breaking = Breaking( cut, kit );
if ( cut.Profile != CutProfile.Steps )
{
foreach ( var leg in legs )
{
yield return Bite( cut, leg ).Breaking( breaking );
}
yield break;
}
var going = cut.StepGoing > 0.5f ? cut.StepGoing : kit.StepGoing;
var rise = cut.StepRise > 0.5f ? cut.StepRise : kit.StepRise;
foreach ( var leg in legs.Where( leg => !leg.HasLoop ) )
{
// The hole starts on the leg's own band, so it agrees with the walls round it.
var climbed = leg.BaseHeight;
var steps = Math.Max( 1, (int)MathF.Round( leg.Length / MathF.Max( 1f, going ) ) );
var tread = leg.Length / steps;
var axes = leg.Axes;
var half = MathF.Max( 1f, leg.Width ) * 0.5f;
for ( var step = 0; step < steps; step++ )
{
var footprint = axes.Rect( step * tread, (step + 1) * tread, -half, half );
climbed += rise;
yield return ArchCarveVolume.Over( footprint, climbed, leg.TopHeight ).Breaking( breaking );
}
}
}
// A box, or the wedge a ramped cut takes: its floor is the surface it leaves behind, so the head of the band
// loses nothing and the foot loses all of it. Through ArchRamp, so the void the ghost draws is the void the
// carve takes and no second slope is derived anywhere.
public static ArchCarveVolume Bite( ArchCutPart cut, ArchCutSegment leg )
{
return ArchRamp.Rakes( cut )
? ArchCarveVolume.Above( leg.Outline(), Floor( cut, leg ), leg.TopHeight )
: ArchCarveVolume.Over( leg.Outline(), leg.BaseHeight, leg.TopHeight );
}
// The plane the bite leaves standing under it - read by the generator, the ghost, the handles and the section.
public static ArchCarvePlane Floor( ArchCutPart cut, ArchCutSegment leg )
{
return ArchRamp.Rakes( cut )
? ArchRamp.Deck( cut, leg.Outline(), leg.TopHeight, leg.TopHeight - leg.BaseHeight )
: ArchCarvePlane.Level( leg.BaseHeight );
}
// The low end of a ramped bite, the way ArchRamp.Foot answers for a platform: the height the foot handle
// stands at, and the one the fall is dragged by.
public static float Toe( ArchCutPart cut, ArchCutSegment leg )
{
return leg.TopHeight - ArchRamp.Fall( cut, leg.TopHeight - leg.BaseHeight );
}
// Seeded from the cut's own id: a ruin that reshuffled itself on every hotload could not be reviewed, and
// every emitted vertex is folded into the incremental build's key.
static ArchCarveBreak Breaking( ArchCutPart cut, ArchKit kit )
{
return cut.BreakEdges ? new ArchCarveBreak { Seed = cut.Id, Jitter = kit?.BreakJitter ?? 0f } : default;
}
// Only legs whose band reaches this slab open it - the storey between stays whole.
// Derived, never stored, as ArchFloorCutout - its arbitrary Loop already carries angled holes.
// World-space: one shaft opens every slab it overlaps in every building it is allowed to reach.
public static bool Affects( ArchCutPart cut, ArchCutAffects target ) => (cut.Affects & target) == target;
// The wells a storey loses because the layer that pierced them is still enabled. Slabs, ceilings,
// foundations and roofs all gather this the same way, so disabling a flight fills its stairwell back
// in without the plan losing the well it would restore.
public static IEnumerable<ArchFloorCutout> Stored( ArchBuilding building, int level )
{
return building.Cutouts.Where( cutout => cutout.Level == level && ArchLayerGate.Owned( cutout.OwnerId ) );
}
public static IEnumerable<ArchFloorCutout> Holes(
ArchPlan plan,
int level,
ArchKit kit,
float bottom,
float top,
int hostId,
ArchCutAffects target = ArchCutAffects.Floors,
int standing = 0 )
{
foreach ( var (cut, volume) in Reaching( plan, level, kit, bottom, top, hostId, target, standing ) )
{
var hole = new ArchFloorCutout { Level = level, Name = cut.Name, Break = volume.Break };
hole.Reshape( volume.Footprint );
yield return hole;
}
}
// The loops a cut opened RIGHT THROUGH a slab and says it wants guarded. An edge a boolean left in a floor is
// a drop exactly as the edge of a stairwell is, so the ring round the well walks these beside its own and every
// rule it already keeps - the mouth left open, an edge against a wall left bare, the flight's own rail owning
// what it reaches - applies to them unchanged.
public static IEnumerable<List<Vector2>> Guarded( ArchPlan plan, int level, ArchKit kit, float bottom, float top, int hostId )
{
var low = MathF.Min( bottom, top );
var high = MathF.Max( bottom, top );
foreach ( var (cut, volume) in Reaching( plan, level, kit, bottom, top, hostId, ArchCutAffects.Floors ) )
{
if ( cut.GuardsOpenedEdges && Pierces( volume, low, high ) )
{
yield return volume.Footprint.ToList();
}
}
}
// The same shafts a slab loses, handed to a solid that carves in three dimensions rather than in plan.
// A roof asks through here so the deck and the ceiling under it read one list.
public static IEnumerable<ArchCarveVolume> Volumes(
ArchPlan plan,
int level,
ArchKit kit,
float bottom,
float top,
int hostId,
ArchCutAffects target,
int standing = 0 )
{
return Reaching( plan, level, kit, bottom, top, hostId, target, standing ).Select( found => found.Volume );
}
public static IEnumerable<(ArchCutPart Cut, ArchCarveVolume Volume)> Damage(
ArchPlan plan,
int level,
ArchKit kit,
float bottom,
float top,
int hostId,
ArchCutAffects target,
int standing = 0 )
{
return Reaching( plan, level, kit, bottom, top, hostId, target, standing, true );
}
static IEnumerable<(ArchCutPart Cut, ArchCarveVolume Volume)> Reaching(
ArchPlan plan,
int level,
ArchKit kit,
float bottom,
float top,
int hostId,
ArchCutAffects target,
int standing = 0,
bool damage = false )
{
if ( plan is null )
{
yield break;
}
var low = MathF.Min( bottom, top );
var high = MathF.Max( bottom, top );
foreach ( var cut in Cuts( plan, level, hostId, target, standing, damage ) )
{
foreach ( var volume in Resolve( cut, kit ) )
{
if ( Reaches( volume, low, high ) )
{
yield return (cut, volume);
}
}
}
}
// A cut opens the slabs of whatever its group holds, and of every building when it stands in none.
// A disabled one is still authored and still selectable - it just stops being read.
//
// It also only reaches what was STANDING when it was made: order of operations is not a boolean feature,
// it is how the whole stack evaluates, so the one answer to that lives in ArchLayerOrder and is asked
// here - the single place a cut chooses what it may touch.
static IEnumerable<ArchCutPart> Cuts( ArchPlan plan, int level, int hostId, ArchCutAffects target, int standing = 0, bool damage = false )
{
foreach ( var building in plan.Buildings )
{
foreach ( var cut in Filed( plan, building.Cuts, building.Id, level, hostId, target, standing, damage ) )
{
yield return cut;
}
}
// A road holds its own, so a bore and a carriageway are opened by the algebra a slab is.
foreach ( var road in plan.Roads() )
{
foreach ( var cut in Filed( plan, road.Cuts, road.Id, level, hostId, target, standing, damage ) )
{
yield return cut;
}
}
}
static IEnumerable<ArchCutPart> Filed(
ArchPlan plan,
IReadOnlyList<ArchCutPart> cuts,
int ownerId,
int level,
int hostId,
ArchCutAffects target,
int standing,
bool damage )
{
foreach ( var cut in cuts )
{
if ( cut.IsDamage != damage || cut.Level > level || !cut.HasContent || !ArchLayerGate.On( cut ) || !Affects( cut, target ) )
{
continue;
}
if ( !ArchLayerOrder.Applies( plan, cut.Id, standing ) )
{
continue;
}
var reach = ArchLayerGroups.Reach( plan, cut.Id, ownerId );
if ( hostId == 0 || reach is null || reach.Contains( hostId ) )
{
yield return cut;
}
}
}
// At the corners: a raked band reaches one end of a pitched deck long before the other.
public static bool Reaches( ArchCarveVolume volume, float low, float high )
{
var floor = float.MaxValue;
var ceiling = float.MinValue;
foreach ( var corner in volume.Footprint )
{
floor = MathF.Min( floor, volume.Floor.At( corner ) );
ceiling = MathF.Max( ceiling, volume.Ceiling.At( corner ) );
}
return ceiling > low + ArchCarve.Grain && floor < high - ArchCarve.Grain;
}
// A volume spanning the whole band it reaches takes that body out entirely - a well, and a well is
// what gets an edge dressed. One that stops inside leaves material over or under it, which is a
// recess, and a recess is trimmed by nothing: its faces ARE the body it was taken out of.
public static bool Pierces( ArchCarveVolume volume, float low, float high )
{
var floor = float.MinValue;
var ceiling = float.MaxValue;
foreach ( var corner in volume.Footprint )
{
floor = MathF.Max( floor, volume.Floor.At( corner ) );
ceiling = MathF.Min( ceiling, volume.Ceiling.At( corner ) );
}
return floor <= low + ArchCarve.Grain && ceiling >= high - ArchCarve.Grain;
}
public static IEnumerable<(float From, float To)> Outside(
ArchPlan plan,
ArchKit kit,
int level,
int hostId,
Vector2 from,
Vector2 to,
float bottom,
float top,
ArchCutAffects target = ArchCutAffects.WallFittings,
int standing = 0 )
{
var blocked = new List<(float From, float To)>();
if ( plan is not null && (to - from).Length > ArchCarve.Grain )
{
foreach ( var cut in Cuts( plan, level, hostId, target, standing ) )
{
foreach ( var volume in Resolve( cut, kit ).Where( volume => Reaches( volume, bottom, top ) ) )
{
blocked.AddRange( ArchFootprint.Inside( volume.Footprint, from, to ) );
}
}
}
var marks = new List<float> { 0f, 1f };
foreach ( var range in blocked )
{
marks.Add( Math.Clamp( range.From, 0f, 1f ) );
marks.Add( Math.Clamp( range.To, 0f, 1f ) );
}
marks = marks.Distinct().OrderBy( value => value ).ToList();
for ( var index = 0; index + 1 < marks.Count; index++ )
{
var start = marks[index];
var finish = marks[index + 1];
var middle = (start + finish) * 0.5f;
if ( finish - start > 0.001f && !blocked.Any( range => middle > range.From && middle < range.To ) )
{
yield return (start, finish);
}
}
}
public static IEnumerable<List<Vector3>> OutsidePath(
ArchPlan plan,
ArchKit kit,
int level,
int hostId,
IReadOnlyList<Vector3> path,
ArchCutAffects target )
{
var runs = new List<List<Vector3>>();
for ( var index = 0; index + 1 < (path?.Count ?? 0); index++ )
{
var from = path[index];
var to = path[index + 1];
var flatFrom = new Vector2( from.x, from.y );
var flatTo = new Vector2( to.x, to.y );
var spans = Outside( plan, kit, level, hostId, flatFrom, flatTo, MathF.Min( from.z, to.z ), MathF.Max( from.z, to.z ), target ).ToList();
foreach ( var span in spans )
{
var start = Vector3.Lerp( from, to, span.From );
var finish = Vector3.Lerp( from, to, span.To );
var current = runs.LastOrDefault();
if ( current is null || (current[^1] - start).Length > 0.05f )
{
current = new List<Vector3> { start };
runs.Add( current );
}
if ( (current[^1] - finish).Length > 0.01f )
{
current.Add( finish );
}
}
if ( spans.Count == 0 || spans.Any( span => span.From > 0.001f || span.To < 0.999f ) )
{
runs.Add( null );
}
}
return runs.Where( run => run is { Count: >= 2 } );
}
// The one cut of a LEVEL run: a ring is closed before it is cut so its seam edge can break like any
// other, then each surviving stretch is normalized (closure detected, duplicated closing point
// dropped) and handed back as a run. Gutters, fascia, soffits, parapets and closures all read this
// answer, so a hole reads the same to every band that crosses it.
public static IEnumerable<ArchRunPath> Runs(
ArchPlan plan,
ArchKit kit,
int level,
int hostId,
ArchRunPath run,
ArchCutAffects target )
{
var path = run.Raised();
if ( run.Closed && path.Count >= 3 )
{
path.Add( path[0] );
}
foreach ( var surviving in OutsidePath( plan, kit, level, hostId, path, target ) )
{
yield return ArchRunPath.Normalized( surviving, run.Height );
}
}
// The 3D form of the same cut: a trim climbs its host's jambs, so its surviving stretches keep
// their own per-point heights and only the closure is answered here. The author closes the ring
// when it is one - ArchTrimFollow.Walked does - so no pre-close is asked for.
public static IEnumerable<(List<Vector3> Points, bool Closed)> Surviving(
ArchPlan plan,
ArchKit kit,
int level,
int hostId,
IReadOnlyList<Vector3> path,
ArchCutAffects target )
{
foreach ( var surviving in OutsidePath( plan, kit, level, hostId, path, target ) )
{
var closed = ArchRunPath.IsClosed( surviving );
yield return (closed ? surviving.Take( surviving.Count - 1 ).ToList() : surviving, closed);
}
}
// Is one POINT inside a cut - the question a fitting too small to have a run of its own asks, like the
// block that takes a ridge's corner. Its band is the fitting's own, so a cut passing under it leaves it.
public static bool Covers( ArchPlan plan, ArchKit kit, int level, int hostId, Vector3 from, Vector3 to, ArchCutAffects target )
{
var at = new Vector2( (from.x + to.x) * 0.5f, (from.y + to.y) * 0.5f );
return Volumes( plan, level, kit, MathF.Min( from.z, to.z ), MathF.Max( from.z, to.z ), hostId, target )
.Any( volume => volume.Covers( at ) );
}
// Asked by the solid's own generator, so nothing is told in advance it will be cut.
public static IEnumerable<ArchCutPart> Over(
ArchPlan plan,
int level,
IReadOnlyList<Vector2> outline,
int hostId,
ArchCutAffects target = ArchCutAffects.Platforms,
int standing = 0 )
{
if ( plan is null || outline is not { Count: >= 3 } )
{
yield break;
}
foreach ( var cut in Cuts( plan, level, hostId, target, standing ) )
{
if ( cut.Outlines().Any( loop => ArchFootprint.Overlaps( loop, outline ) ) )
{
yield return cut;
}
}
}
// Each leg continues the last, lifted by the chain's Rise - one part can wind round a tower.
// The first leg has nothing to continue from - it takes the band it was given.
public static ArchCutSegment Extend( ArchCutPart cut, Vector2 from, Vector2 to, float width, float baseHeight, float topHeight )
{
var segment = Next( cut, from, to, width, baseHeight, topHeight );
if ( segment is null )
{
return null;
}
cut.Segments.Add( segment );
return segment;
}
// Ghost and commit both go through here - the leg you are shown is the leg you get.
public static ArchCutSegment Next( ArchCutPart cut, Vector2 from, Vector2 to, float width, float baseHeight, float topHeight, bool? snapAngle = null )
{
var last = cut?.Segments.Count > 0 ? cut.Segments[^1] : null;
var lift = last is null ? 0f : cut.Rise;
return Sketch(
last?.End ?? from,
to,
width,
(last?.BaseHeight ?? baseHeight) + lift,
(last?.TopHeight ?? topHeight) + lift,
snapAngle ?? cut?.SnapAngle ?? true );
}
// A LOOP leg is one shape described twice - the loop the carve takes, and the run the handles, the ghost and
// the carve frame read off Start/End. So the run is DERIVED from the loop: centred on it, along the yaw it
// already stands at, measured to its own extremes. Author the two separately and every drag drifts them a
// rounding further apart, until the widget, the preview and the geometry are three different shapes.
public static void Fit( ArchCutSegment segment, float yaw )
{
if ( segment is null || !segment.HasLoop )
{
return;
}
var axes = new ArchStairAxes { Yaw = yaw };
var along = axes.Along;
var centre = segment.Loop.Aggregate( Vector2.Zero, ( total, point ) => total + point ) / segment.Loop.Count;
var reach = segment.Loop.Select( point => Vector2.Dot( point - centre, along ) ).ToList();
segment.Start = centre + along * reach.Min();
segment.End = centre + along * reach.Max();
}
// Rigid: the loop and the run travel together and neither is re-derived, so a move cannot turn or stretch it.
public static void Shift( ArchCutSegment segment, Vector2 by )
{
if ( segment is null || by.Length < ArchGridService.FinestSize )
{
return;
}
segment.Start += by;
segment.End += by;
if ( segment.HasLoop )
{
segment.Loop = segment.Loop.Select( point => point + by ).ToList();
}
}
// The loop swings and the run is refitted to where it landed, so the yaw the frame reads is the yaw the
// shape actually stands at. The SWING is what lands on the ladder, not the angle it arrives at: rounding the
// total dragged a shape drawn at seven degrees onto the nearest fifteen the moment the ring was touched, and
// an edit is supposed to leave where a shape already stands alone. The handle has already ratcheted, so this
// is idempotent for a stepped drag and only guards a caller that has not.
public static void Turn( ArchCutSegment segment, Vector2 about, float degrees, bool snapAngle )
{
if ( segment is null )
{
return;
}
var swing = snapAngle ? ArchGridService.Snap( degrees, ArchGridService.AngleStep ) : degrees;
var wanted = segment.Yaw + swing;
if ( MathF.Abs( swing ) < 0.001f )
{
return;
}
if ( segment.HasLoop )
{
segment.Loop = ArchFootprint.Turned( segment.Loop, about, swing );
Fit( segment, wanted );
return;
}
var ends = ArchFootprint.Turned( new[] { segment.Start, segment.End }, about, swing );
segment.Start = ends[0];
segment.End = ends[1];
}
// Redrawn through Sketch, so a handled leg is one the tool would have let you draw.
public static bool Reshape( ArchCutSegment segment, Vector2 from, Vector2 to, bool snapAngle = true )
{
// A loop leg's run is the loop's, not the drag's - re-sketching it here is what let the two disagree.
if ( segment is { HasLoop: true } )
{
Shift( segment, from - segment.Start );
return true;
}
if ( segment is null || Sketch( from, to, segment.Width, segment.BaseHeight, segment.TopHeight, snapAngle ) is not { } redrawn )
{
return false;
}
segment.Start = redrawn.Start;
segment.End = redrawn.End;
return true;
}
// The chain invariant - each leg keeps its own vector, so this slides the chain after edits.
public static void Relink( ArchCutPart cut )
{
if ( cut is null )
{
return;
}
for ( var index = 1; index < cut.Segments.Count; index++ )
{
var previous = cut.Segments[index - 1];
var leg = cut.Segments[index];
Reshape( leg, previous.End, previous.End + (leg.End - leg.Start), cut.SnapAngle );
}
}
public static ArchCutSegment Sketch( Vector2 from, Vector2 to, float width, float baseHeight, float topHeight, bool snapAngle = true )
{
var span = to - from;
if ( span.Length < MinRun )
{
return null;
}
// Whole degrees stay clean; 15 degree steps are the ladder unless the part says otherwise.
var yaw = ArchGridService.Snap( MathF.Atan2( span.y, span.x ).RadianToDegree(), snapAngle ? ArchGridService.AngleStep : 1f ).DegreeToRadian();
var along = new Vector2( MathF.Cos( yaw ), MathF.Sin( yaw ) );
return new ArchCutSegment
{
Start = from,
End = from + along * ArchGridService.Fine( Vector2.Dot( span, along ) ),
Width = MathF.Max( 1f, width ),
BaseHeight = MathF.Min( baseHeight, topHeight ),
TopHeight = MathF.Max( baseHeight, topHeight )
};
}
}