Editor utility for walkway/bridge links between buildings. It contains creation, reshaping, seating, raising, width/piers/interior management and geometry helpers for walkway ArchRoom, decks, piers and stairs.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// A link between two buildings: the drag is the span, the buildings are cut out of it, and the
// leftover rectangle becomes the walkway - side walls on the long sides, a deck overhead, piers
// under. Dragging from a lower building to a higher one makes the link climb: the deck becomes a
// shed following the rise and the interior carries the climb as a flight, a ramp or a plain slope.
// Glazes NOTHING: a sash preset needs 100 in of wall but a link stands 96 clear.
public static class ArchWalkway
{
public const float MinWidth = 36f;
internal const float MinSpan = 48f;
const string FlightName = "WalkwayFlight";
public sealed class Link
{
public ArchRoom Room { get; init; }
public ArchRoofPart Roof { get; init; }
public ArchBuilding Host { get; init; }
public IReadOnlyList<ArchWalkwayEndpoint> Endpoints { get; init; } = Array.Empty<ArchWalkwayEndpoint>();
public int Breached { get; init; }
public bool Rising { get; init; }
}
public static Link Add(
ArchPlan plan,
ArchBuilding building,
ArchKit kit,
int level,
float baseHeight,
Vector2 dragMin,
Vector2 dragMax,
Vector2 dragFrom,
float width,
float height,
ArchPillarPart pier,
int rise,
WalkwayInterior interior,
bool cornice,
bool skirting )
{
return new ArchWalkwayBuilder( plan, building, kit )
.Between( level, baseHeight, dragMin, dragMax, dragFrom )
.WithWidth( width )
.WithHeight( height )
.WithPiers( pier )
.WithRise( rise )
.WithInterior( interior )
.WithCornice( cornice )
.WithSkirting( skirting )
.Create();
}
// The deck is a roof filed on the host building, so the link alone cannot name it. Its layer
// record can - that is the durable statement of which link a deck belongs to, and it is what
// survives the two drifting apart. A link authored before records falls back to the flat roof
// standing over its own footprint, which only holds while they still agree.
public static ArchRoofPart Deck( ArchPlan plan, ArchRoom link )
{
if ( link is null )
{
return null;
}
var decks = plan.Buildings.SelectMany( building => building.Roofs ).Where( roof => roof.Walkway ).ToList();
var owned = plan.Layers
.Where( record => record.ParentId == link.Id )
.Select( record => decks.FirstOrDefault( roof => roof.Id == record.ItemId ) )
.FirstOrDefault( roof => roof is not null );
if ( owned is not null )
{
return owned;
}
var loop = ArchFloorGen.Footprint( link );
if ( loop.Count < 3 )
{
return null;
}
ArchFootprint.Bounds( loop, out var min, out var max );
return decks.FirstOrDefault( roof => roof.Level == link.Floor
&& (roof.Min - min).Length < 8f && (roof.Max - max).Length < 8f );
}
// A link is walls, a deck, piers and whatever climbs inside it, and every part of it rides one
// resolution. The drag is AUTHORITATIVE here, unlike placement: each end is only pushed onto the
// host wall it now meets, so it never floats in the middle of a wall and never comes home to a
// gap the author did not drag it into.
public static void Reshape( ArchPlan plan, ArchRoom link, IReadOnlyList<Vector2> outline, ArchKit kit = null )
{
var loop = ArchFloorGen.Footprint( link );
if ( loop.Count < 3 || outline is not { Count: >= 3 } )
{
return;
}
ArchFootprint.Bounds( loop, out var fromMin, out var fromMax );
ArchFootprint.Bounds( outline, out var toMin, out var toMax );
if ( !Passable( toMin, toMax ) )
{
return;
}
var span = Resolved( plan, link, kit, ref toMin, ref toMax );
var map = ArchRemap.Between( fromMin, fromMax, toMin, toMax );
outline = ArchFootprint.Rect( toMin, toMax );
ArchCarry.Room( link, map );
link.Footprint = ArchFootprint.Authored( outline ).Loop;
if ( span is not null )
{
SeatSides( link, span );
}
if ( Deck( plan, link ) is not { } deck )
{
return;
}
// Never the room's box remapped: the deck stops on the host walls' CENTRELINES while the slab
// runs on to their inner faces, so it is re-seated from the same span the slab took rather
// than scaled beside it - scaling the two together walked the ceiling into the host wall every
// reshape. Reshaped in one statement, so its outline and its box can never disagree.
if ( span is not null )
{
deck.Reshape( ArchFootprint.Rect( span.DeckMin, span.DeckMax ) );
return;
}
var covered = deck.Outline();
map.Loop( covered );
deck.Reshape( covered );
}
// The long side is the run, and this is the ONLY place that says so: a reshape, the deck's ridge and the
// cornice under it all read it, and three derivations of one axis are three chances to disagree about
// which two sides of a passage are its mouths.
public static bool RunsAlongX( Vector2 min, Vector2 max ) => max.x - min.x >= max.y - min.y;
// The two extents a link must keep to still be one: a passage you can walk down and a span worth
// crossing. A drag inside either is REFUSED rather than corrected - correcting it means choosing a
// centre, and the side the author never touched is the one that pays for the choice.
static bool Passable( Vector2 min, Vector2 max )
{
var alongX = RunsAlongX( min, max );
return (alongX ? max.y - min.y : max.x - min.x) >= MinWidth
&& (alongX ? max.x - min.x : max.y - min.y) >= MinSpan;
}
// The same seating placement runs, but never its SEARCH: an edit already says where the link
// stands, so each end is pushed onto the host wall it now meets and nothing looks for a gap to put
// the link in. Without a kit there is nothing to seat against, so the drag stands.
static ArchWalkwaySpan Resolved( ArchPlan plan, ArchRoom link, ArchKit kit, ref Vector2 min, ref Vector2 max )
{
if ( kit is null )
{
return null;
}
var rise = link.WalkwayTop > link.BaseHeight + 1f ? 1 : 0;
var span = new ArchWalkwayGeometryService( new ArchConnectionService( plan, kit ) )
.Reseat( link.Floor, rise, min, max, link );
if ( span is null )
{
return null;
}
min = span.Min;
max = span.Max;
return span;
}
// The sides stand between the same ends the deck does, on the host walls' centrelines. The slab runs
// half a wall further, onto the inner face, and a side taken the whole way caps on the room's own
// wall plane - inside the room beyond it. Re-seated rather than remapped, exactly as the deck is.
internal static void SeatSides( ArchRoom link, ArchWalkwaySpan span )
{
var from = span.AlongX ? span.DeckMin.x : span.DeckMin.y;
var to = span.AlongX ? span.DeckMax.x : span.DeckMax.y;
foreach ( var wall in link.Walls )
{
var runs = span.AlongX ? MathF.Abs( wall.Direction.x ) : MathF.Abs( wall.Direction.y );
if ( runs < 0.7f )
{
continue;
}
var forward = span.AlongX ? wall.End.x >= wall.Start.x : wall.End.y >= wall.Start.y;
wall.Start = Along( wall.Start, span.AlongX, forward ? from : to );
wall.End = Along( wall.End, span.AlongX, forward ? to : from );
}
}
static Vector2 Along( Vector2 point, bool alongX, float value )
{
return alongX ? new Vector2( value, point.y ) : new Vector2( point.x, value );
}
// Lifts the whole link off the ground: floor, walls, deck, piers and the climb inside it.
public static void Raise( ArchPlan plan, ArchRoom link, ArchKit kit, float baseHeight )
{
var lift = baseHeight - link.BaseHeight;
if ( MathF.Abs( lift ) < 0.01f )
{
return;
}
link.BaseHeight = baseHeight;
if ( link.WalkwayTop > 0f )
{
link.WalkwayTop += lift;
}
foreach ( var stair in link.Stairs )
{
stair.BaseHeight += lift;
}
foreach ( var pier in link.Pillars )
{
// The pier's foot stays on the ground; only its head follows the deck it holds up.
pier.Height = MathF.Max( 1f, pier.Height + lift );
}
if ( Deck( plan, link ) is { } deck )
{
deck.BaseHeight += lift;
}
}
// No width: nothing can un-cut the host walls the mouths were breached through.
public static void Reheight( ArchRoom room, ArchRoofPart deck, ArchKit kit, float height )
{
var clear = MathF.Max( 60f, height );
var standing = ArchContact.Bury( kit, clear, -1f );
room.WallHeight = standing;
foreach ( var wall in room.Walls )
{
wall.Height = standing;
}
if ( deck is not null )
{
deck.BaseHeight = room.BaseHeight + clear;
}
foreach ( var pier in room.Pillars )
{
pier.Height = RiseIntoSlabFrom( room, kit, pier.BaseHeight );
}
}
static float RiseIntoSlabFrom( ArchRoom room, ArchKit kit, float from )
{
return ArchContact.Bury( kit, room.BaseHeight - kit.FloorThickness, -1f ) - from;
}
// The passage widened or narrowed where it stands. Nothing about a mouth is permanent - the affector
// re-derives every one of them from the link's current footprint on each commit - so this is just a
// reshape that leaves the run alone and sets the across extent about its own centreline. It goes through
// the ONE reshape path, so the ends re-seat on their host walls exactly as a handle drag's do.
public static void SetWidth( ArchPlan plan, ArchRoom link, ArchKit kit, float width )
{
var loop = ArchFloorGen.Footprint( link );
if ( loop.Count < 3 )
{
return;
}
ArchFootprint.Bounds( loop, out var min, out var max );
var alongX = RunsAlongX( min, max );
var centre = (min + max) * 0.5f;
var half = MathF.Max( MinWidth, width ) * 0.5f;
Reshape( plan, link, ArchFootprint.Rect(
alongX ? new Vector2( min.x, centre.y - half ) : new Vector2( centre.x - half, min.y ),
alongX ? new Vector2( max.x, centre.y + half ) : new Vector2( centre.x + half, max.y ) ), kit );
}
// What a placed link's passage measures across, so the box shows what is standing rather than the seed.
public static float WidthOf( ArchRoom link )
{
var loop = ArchFloorGen.Footprint( link );
if ( loop.Count < 3 )
{
return 0f;
}
ArchFootprint.Bounds( loop, out var min, out var max );
return RunsAlongX( min, max ) ? max.y - min.y : max.x - min.x;
}
internal const float PierClearance = 12f;
// Piers only mean anything under a link standing clear of the ground: below this there is no gap to fill
// and the support service refuses. Asked here so the sidebar offering the toggle and the service standing
// them cannot disagree - a toggle that flips and then quietly stands nothing reads as a dead control.
public static bool Raised( ArchRoom link, ArchKit kit )
{
return link is not null && link.BaseHeight - kit.GroundClearance >= PierClearance;
}
// Piers stood under a PLACED link, or taken away, from where it stands now - through the same support
// service the drag used, so a link given piers after the fact gets the bays the drag would have given it.
public static void SetPiers( ArchPlan plan, ArchRoom link, ArchKit kit, ArchPillarPart source )
{
var kinds = ArchKinds.Load();
plan.RemoveAll( ArchKind.Pillar, link,
pillar => kinds.NameOf( ArchKind.Pillar, pillar, 0 ) == ArchWalkwaySupportService.PartName, kinds );
if ( source is null )
{
return;
}
ArchFootprint.Bounds( ArchFloorGen.Footprint( link ), out var min, out var max );
new ArchWalkwaySupportService( plan, plan.OwnerOf( link ), link, kit )
.AddPiers( source, min, max, RunsAlongX( min, max ) );
}
// The interior of a rising link: a flight the stair generator builds, or the flag that makes
// the link's own generator draw a ramp or a plain slope instead of the flat floor.
public static void SetInterior( ArchPlan plan, ArchRoom room, ArchKit kit, WalkwayInterior interior )
{
var kinds = ArchKinds.Load();
room.Interior = interior;
plan.RemoveAll( ArchKind.Stair, room, stair => kinds.NameOf( ArchKind.Stair, stair, 0 ) == FlightName, kinds );
if ( interior != WalkwayInterior.Stairs || room.Footprint.Count < 4 )
{
return;
}
ArchFootprint.Bounds( room.Footprint, out var min, out var max );
var alongX = (max - min).x >= (max - min).y;
var half = kit.WallThickness * 0.5f;
var width = (alongX ? max.y - min.y : max.x - min.x) - kit.WallThickness;
var run = alongX ? max.x - min.x : max.y - min.y;
var rise = MathF.Max( 1f, room.WalkwayTop - room.BaseHeight );
var steps = Math.Max( 1, (int)MathF.Round( rise / MathF.Max( 3f, kit.StepRise ) ) );
var going = MathF.Min( MathF.Max( 6f, kit.StepGoing ), MathF.Max( 24f, run * 0.45f / steps ) );
var flight = new ArchStairPart
{
Id = plan.AllocateId(),
Name = FlightName,
BaseHeight = room.BaseHeight,
Width = MathF.Max( 24f, width ),
StepRise = rise / steps,
StepGoing = going,
TopLanding = false,
WellGuard = false,
Guard = StairGuard.None,
// Closed: a link stands clear of the ground, so its raw underside is the face the street
// sees. Switch it back to Open on the flight to show the steps.
Under = StairUnder.Filled,
TreadThickness = 2.5f
};
// The leg leaves the passage's low corner; the flight's width extends to its left,
// which is the passage's other side - so the climb sits between the raked walls.
var start = alongX
? new Vector2( min.x, min.y + half )
: new Vector2( max.x - half, min.y );
var end = alongX ? new Vector2( max.x, start.y ) : new Vector2( start.x, max.y );
var (core, lanes) = ArchStairCore.Straight( start, end - start, (end - start).Length, flight.Width, rise );
flight.Core = core;
flight.Lanes = lanes;
plan.File( ArchKind.Stair, room, flight, kinds );
}
// Long side of the drag is the run, short side the width; the Width box fills in a thin drag.
public static void Shape( Vector2 dragMin, Vector2 dragMax, float width, out Vector2 min, out Vector2 max, out bool alongX )
{
alongX = dragMax.x - dragMin.x >= dragMax.y - dragMin.y;
var dragged = alongX ? dragMax.y - dragMin.y : dragMax.x - dragMin.x;
var across = dragged >= MinWidth ? dragged : MathF.Max( MinWidth, width );
var centre = (dragMin + dragMax) * 0.5f;
var half = across * 0.5f;
min = alongX ? new Vector2( dragMin.x, centre.y - half ) : new Vector2( centre.x - half, dragMin.y );
max = alongX ? new Vector2( dragMax.x, centre.y + half ) : new Vector2( centre.x + half, dragMax.y );
}
}