Editor code for stair piercing in an architectural editor. It computes stairwell cutouts and wall openings for stair runs across buildings and storeys, creates/removes ArchFloorCutout and ArchOpening entries, and handles scope/ownership and level conversions.
using System;
using System.Linq;
using System.Collections.Generic;
using Sandbox;
namespace Sunless.Architecture;
public sealed partial class ArchStairShape
{
// Holes are keyed to the stair that made them: re-piercing replaces, deleting removes.
public static void Pierce( ArchPlan plan, ArchBuilding building, ArchRoom room, ArchStairPart stair, ArchKit kit )
{
if ( building is null )
{
return;
}
// Filing on another storey cuts the shaft through the wrong slab — say why out loud.
if ( room is not null && MathF.Abs( stair.BaseHeight - room.BaseHeight ) > 1f )
{
Log.Warning( $"Architecture: stair {stair.Name} starts at {stair.BaseHeight:0.#} but {room.Name}'s floor is at {room.BaseHeight:0.#} - it is filed on the wrong storey." );
}
var shape = Resolve( stair, room, kit, building.Rooms, building );
// A flight drawn out of one building and into the next has to open BOTH: the far building's
// slabs know nothing about a stair arriving up through them. How far "the next" goes is the
// group the flight was filed in - outside that scope it has nothing it is allowed to open.
var reach = ArchLayerGroups.Reach( plan, stair.Id, building.Id );
foreach ( var host in plan.Buildings.Where( host => host == building || reach is null || reach.Contains( host.Id ) ) )
{
Open( plan, host, stair, shape, kit, host == building );
}
Breach( plan, stair, shape, kit, reach );
}
// The loops one building's own slabs lose. A neighbour's storeys need not line up with the flight's,
// so its level is read back off the well's height rather than carried across.
static void Open( ArchPlan plan, ArchBuilding host, ArchStairPart stair, ArchStairShape shape, ArchKit kit, bool owner )
{
// Recycled ids keep an unchanged flight byte-identical, so a rebuild costs no undo entry.
var recycled = new Queue<int>( host.Cutouts.Where( cutout => cutout.OwnerId == stair.Id ).Select( cutout => cutout.Id ) );
host.Cutouts.RemoveAll( cutout => cutout.OwnerId == stair.Id );
foreach ( var well in shape.Levels )
{
var level = owner ? well.Level : FloorIndex( host, kit, well.Height );
if ( !owner && MathF.Abs( FloorOf( host, kit, level ) - well.Height ) > 1f )
{
continue;
}
foreach ( var loop in well.Loops )
{
if ( !owner && !Standing( host, level, loop ) )
{
continue;
}
var cutout = new ArchFloorCutout
{
Id = recycled.Count > 0 ? recycled.Dequeue() : plan.AllocateId(),
Name = $"Stairwell{stair.Id}",
Level = level,
OwnerId = stair.Id
};
cutout.Reshape( loop );
host.Cutouts.Add( cutout );
}
}
}
// Bounds, not containment: a well that only clips the corner of a slab still takes a bite out of it.
static bool Standing( ArchBuilding host, int level, IReadOnlyList<Vector2> loop )
{
ArchFootprint.Bounds( loop, out var min, out var max );
foreach ( var room in host.Rooms.Where( entry => entry.Floor == level && entry.HasFloor ) )
{
var footprint = ArchFloorGen.Footprint( room );
if ( footprint.Count < 3 )
{
continue;
}
ArchFootprint.Bounds( footprint, out var low, out var high );
if ( low.x <= max.x && high.x >= min.x && low.y <= max.y && high.y >= min.y )
{
return true;
}
}
return false;
}
// The walls a flight walks THROUGH, inside the scope it was filed in. A run that crosses a wall is
// given an archway as wide as the flight and as tall as the headroom over its rake; a wall the flight
// merely runs alongside is left alone, because that is the wall it is meant to hug. Keyed to the stair
// the same way its stairwell is, so re-piercing replaces the archways and deleting the flight closes
// them - closing stays plan-wide whatever the scope, or a regrouped flight would leave holes behind.
public static void Breach( ArchPlan plan, ArchStairPart stair, ArchStairShape shape, ArchKit kit, IReadOnlySet<int> reach = null )
{
var headroom = MathF.Max( 1f, kit.StairHeadroom );
var clearance = ArchProbe.Step;
var kinds = ArchKinds.Load();
foreach ( var room in plan.AllRooms() )
{
var wallHeight = ArchFloorGen.WallHeight( room, kit );
var outside = reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id );
foreach ( var wall in plan.Filed( ArchKind.Wall, room, kinds ).OfType<ArchWall>() )
{
plan.RemoveAll( ArchKind.Opening, wall, opening => opening is ArchOpening archway && archway.OwnerId == stair.Id, kinds );
if ( outside )
{
continue;
}
foreach ( var run in shape.Runs )
{
if ( !Crossing( wall, run.Axes, run.Length, run.Width, out var from, out var to, out var alongFrom, out var alongTo ) )
{
continue;
}
var under = MathF.Min( run.Rake( alongFrom ), run.Rake( alongTo ) ) - MathF.Max( 1f, stair.TreadThickness );
var over = MathF.Max( run.Rake( alongFrom ), run.Rake( alongTo ) ) + headroom;
var sill = MathF.Max( 0f, under - room.BaseHeight );
var head = MathF.Min( wallHeight, over - room.BaseHeight );
if ( head - sill < 1f )
{
continue;
}
plan.File( ArchKind.Opening, wall, new ArchOpening
{
Id = plan.AllocateId(),
OwnerId = stair.Id,
Preset = "archway",
Kind = OpeningKind.Archway,
Offset = (from + to) * 0.5f,
Width = to - from + clearance * 2f,
SillHeight = sill,
Height = head - sill,
Cased = false,
HasSill = false,
Leaf = false,
Glazed = false,
AutoLayout = false
}, kinds );
}
}
}
}
// The wall's centreline clipped to a run's rectangle, as stations along the wall and as the run's own
// along at each end - the rake is what decides how tall the hole has to be.
static bool Crossing( ArchWall wall, ArchStairAxes axes, float length, float width, out float from, out float to, out float alongFrom, out float alongTo )
{
from = 0f;
to = 0f;
alongFrom = 0f;
alongTo = 0f;
var span = wall.End - wall.Start;
var reach = span.Length;
if ( reach < 1f )
{
return false;
}
var direction = span / reach;
// A wall the flight runs ALONG is the wall it hugs; only one it walks through is opened.
if ( MathF.Abs( Vector2.Dot( direction, axes.Along ) ) > 0.7f )
{
return false;
}
var low = 0f;
var high = reach;
var offset = wall.Start - axes.Origin;
if ( !Slab( Vector2.Dot( offset, axes.Along ), Vector2.Dot( direction, axes.Along ), 0f, length, ref low, ref high )
|| !Slab( Vector2.Dot( offset, axes.Across ), Vector2.Dot( direction, axes.Across ), 0f, width, ref low, ref high )
|| high - low < 1f )
{
return false;
}
from = low;
to = high;
alongFrom = Vector2.Dot( offset + direction * low, axes.Along );
alongTo = Vector2.Dot( offset + direction * high, axes.Along );
return true;
}
// One axis of the box clip: how much of the wall stays inside that axis' bounds.
static bool Slab( float start, float rate, float min, float max, ref float low, ref float high )
{
if ( MathF.Abs( rate ) < 0.0001f )
{
return start >= min && start <= max;
}
var first = (min - start) / rate;
var second = (max - start) / rate;
low = MathF.Max( low, MathF.Min( first, second ) );
high = MathF.Min( high, MathF.Max( first, second ) );
return high > low;
}
}