Editor utility for wall join and opening calculations in an architectural editor. Computes how wall boards end, mitre/cap/abut behavior, landing/wrapping/skirting, shared openings, breaches (cutting openings), alignment checks and other geometric helpers.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// How a run-like board stops. Mitred continues round a corner and needs no cap; Butted dies on a
// face it meets; Cut dies at an opening's edge; Free is nothing there at all - the defect arch_audit
// hunts for, and the reason this is a vocabulary rather than a bool.
public enum ArchBandStop
{
Mitred,
Butted,
Cut,
Free,
Returned
}
public readonly record struct ArchBandEnd( float Inner, float Outer, ArchBandStop Stop, float Return = 0f )
{
public bool Capped => Stop is not (ArchBandStop.Mitred or ArchBandStop.Returned);
}
public readonly struct ArchWallJoin
{
public float StartExtend { get; init; }
public float EndExtend { get; init; }
public bool StartJoined { get; init; }
public bool EndJoined { get; init; }
public float StartMitre { get; init; }
public float EndMitre { get; init; }
public bool StartSkirted { get; init; }
public bool EndSkirted { get; init; }
public bool StartLanded { get; init; }
public bool EndLanded { get; init; }
public bool StartWraps { get; init; }
public bool EndWraps { get; init; }
// A board turning a mouth's jamb turns on the FAR face of the wall it lands on, not on its own end:
// the wall stops at the near face, the reveal past it is the same plane, and the board crosses it to
// mitre with the skirting standing on the room's own face.
public float StartCrossing { get; init; }
public float EndCrossing { get; init; }
public float Length { get; init; }
public float HalfThickness { get; init; }
public IReadOnlyList<float> Landings { get; init; }
public IReadOnlyList<float> SkirtedLandings { get; init; }
// However far either face reaches, in or out: a wall that lands on another pulls its cap back to
// that wall's face, and flooring these at the authored end left the cap behind the field it caps.
public float From => MathF.Min( StartMitre, StartExtend );
public float To => MathF.Max( EndMitre, EndExtend );
public float SkirtFrom => StartMitre;
public float SkirtTo => EndMitre;
public float EndMitreCut => EndMitre - Length;
public float EndExtendCut => EndExtend - Length;
public bool StartCapped => !StartJoined;
public bool EndCapped => !EndJoined;
// An inside corner cuts the FACE short, so the back runs on past by the board's depth to the
// corner of the two wall faces; wrapping an outside corner is the same mitre the other way up,
// and it is the face that runs past, round the jamb, to meet the board coming the other way.
public ArchBandEnd Foot( float depth )
{
if ( StartWraps )
{
return new( StartCrossing, StartCrossing - depth, ArchBandStop.Mitred );
}
if ( StartSkirted )
{
return new( SkirtFrom, StartMitre * Splay( depth ), ArchBandStop.Mitred );
}
return new( SkirtFrom, SkirtFrom, StartLanded ? ArchBandStop.Butted : ArchBandStop.Free );
}
public ArchBandEnd Head( float depth )
{
if ( EndWraps )
{
return new( EndCrossing, EndCrossing + depth, ArchBandStop.Mitred );
}
if ( EndSkirted )
{
return new( SkirtTo, Length + (EndMitre - Length) * Splay( depth ), ArchBandStop.Mitred );
}
return new( SkirtTo, SkirtTo, EndLanded ? ArchBandStop.Butted : ArchBandStop.Free );
}
public ArchBandEnd ExteriorFoot( float depth )
{
return new(
StartExtend,
StartExtend * Splay( depth ),
StartJoined ? ArchBandStop.Mitred : ArchBandStop.Free );
}
public ArchBandEnd ExteriorHead( float depth )
{
return new(
EndExtend,
Length + EndExtendCut * Splay( depth ),
EndJoined ? ArchBandStop.Mitred : ArchBandStop.Free );
}
// A board reaching a station another wall lands on turns the corner there instead of dying square -
// onto that wall's own board. Landings are also where the FIELD splits, which every wall does whether
// it is skirted or not, so the two lists are not the same one.
public bool WrapsAt( float station )
{
return SkirtedLandings is not null && SkirtedLandings.Any( landing => MathF.Abs( landing - station ) <= 1f );
}
float Splay( float depth ) => HalfThickness > 0.01f ? (HalfThickness + depth) / HalfThickness : 1f;
}
public static class ArchWallJoins
{
const float CornerTolerance = 1.5f;
const float MaxExtend = 512f;
public static ArchWallJoin For( ArchWall wall, ArchRoom room, ArchBuilding building, float thickness )
{
return For( wall, room, Neighbours( room, building ), thickness );
}
public static ArchWallJoin For( ArchWall wall, ArchRoom room, IEnumerable<ArchWall> neighbours, float thickness )
{
var nearby = neighbours?.ToList() ?? new List<ArchWall>();
var half = Half( wall, thickness );
var start = Resolve( wall, nearby, thickness, true, half );
var end = Resolve( wall, nearby, thickness, false, half );
var landings = Landings( wall, nearby, thickness );
return new ArchWallJoin
{
StartExtend = start.Exterior,
EndExtend = end.Exterior,
StartJoined = start.Joined,
EndJoined = end.Joined,
StartMitre = start.Interior,
EndMitre = end.Interior,
StartSkirted = start.Skirted,
EndSkirted = end.Skirted,
StartLanded = start.Landed,
EndLanded = end.Landed,
StartWraps = start.Wraps,
EndWraps = end.Wraps,
StartCrossing = start.Crossing,
EndCrossing = end.Crossing,
Length = wall.Length,
HalfThickness = half,
Landings = landings.All,
SkirtedLandings = landings.Skirted
};
}
sealed record EndResolution( float Interior, float Exterior, bool Joined, bool Skirted, bool Landed, bool Wraps, float Crossing );
static EndResolution Resolve( ArchWall wall, List<ArchWall> neighbours, float thickness, bool atStart, float half )
{
var corner = atStart ? wall.Start : wall.End;
var body = atStart ? wall.Direction : -wall.Direction;
var outward = -body;
var joined = false;
var skirted = false;
var wraps = false;
var signedInterior = 0f;
var signedExterior = 0f;
float? landing = null;
var through = 0f;
foreach ( var other in neighbours )
{
if ( ReferenceEquals( other, wall ) || other.Length < 0.5f )
{
continue;
}
if ( Touches( other, corner, out var otherBody ) )
{
var cut = Cut( wall, body, otherBody, half );
if ( !joined || MathF.Abs( cut.Interior ) > MathF.Abs( signedInterior ) )
{
signedInterior = cut.Interior;
signedExterior = cut.Exterior;
joined = true;
skirted = other.Baseboard;
}
continue;
}
if ( !Butts( wall, other, corner, outward, thickness, out var reach, out var across ) )
{
continue;
}
if ( landing is null || MathF.Abs( reach ) < MathF.Abs( landing.Value ) )
{
landing = reach;
through = across;
// Only onto a board: a mitre is two boards meeting, and a wall with no skirting on it
// leaves the turn hanging open exactly as a corner would.
wraps = Breached( other, corner, half ) && other.Baseboard;
}
}
if ( joined )
{
var limit = wall.Length * 0.45f;
var interior = Math.Clamp( signedInterior, -limit, MaxExtend );
var exterior = Math.Clamp( signedExterior, -limit, MaxExtend );
return new EndResolution(
At( atStart, wall.Length, interior ),
At( atStart, wall.Length, exterior ),
true,
skirted,
true,
false,
At( atStart, wall.Length, interior ) );
}
// Nothing met: the end stays where it was authored, which for the END of a wall is its LENGTH.
// Returning a bare zero ran every unmet end back to the wall's start and left a board 8 long.
if ( landing is null )
{
var kept = At( atStart, wall.Length, 0f );
return new EndResolution( kept, kept, false, false, false, false, kept );
}
// A reach is measured the way the wall travels out of this end, and at the START that is the way
// the stations run BACK - unflipped, an end that had to retract drove deeper in instead.
var at = At( atStart, wall.Length, atStart ? -landing.Value : landing.Value );
var over = At( atStart, wall.Length, atStart ? -through : through );
return new EndResolution( at, at, false, false, true, wraps, over );
}
// A board dying on a wall face turns the corner instead where THIS wall's own face is the edge of
// a hole in that one - a walkway mouth's jamb, whether the host wall carries an archway or was
// stubbed back to nothing. Measured off the faces, not the centreline: a mouth opens between the
// passage walls' inner faces, so the wall landing on it always stands half a thickness outside.
static bool Breached( ArchWall other, Vector2 corner, float half )
{
var along = Vector2.Dot( corner - other.Start, other.Direction );
if ( along - half < CornerTolerance || along + half > other.Length - CornerTolerance )
{
return true;
}
return other.Openings.Any( opening => opening.SillHeight < 1f
&& (MathF.Abs( along - half - opening.Right ) <= CornerTolerance || MathF.Abs( along + half - opening.Left ) <= CornerTolerance) );
}
static float At( bool atStart, float length, float value ) => atStart ? value : length + value;
static (float Interior, float Exterior) Cut( ArchWall wall, Vector2 body, Vector2 otherBody, float half )
{
var sum = body + otherBody;
var reach = sum.Length;
if ( reach < 0.01f )
{
return (0f, 0f);
}
var mitre = sum / reach;
var direction = wall.Direction;
var normal = wall.Normal;
var across = direction.x * mitre.y - direction.y * mitre.x;
if ( MathF.Abs( across ) < 0.001f )
{
return (0f, 0f);
}
var lean = normal.x * mitre.y - normal.y * mitre.x;
var cut = half * lean / across;
return (-cut, cut);
}
static float Half( ArchWall wall, float fallback ) => (wall.Thickness > 0f ? wall.Thickness : fallback) * 0.5f;
static List<ArchWall> Neighbours( ArchRoom room, ArchBuilding building )
{
if ( building is null )
{
return room.Walls;
}
return building.Rooms
.Where( other => MathF.Abs( other.BaseHeight - room.BaseHeight ) < 0.5f )
.SelectMany( other => other.Walls )
.ToList();
}
static bool Touches( ArchWall other, Vector2 corner, out Vector2 outward )
{
if ( (other.Start - corner).Length <= CornerTolerance )
{
outward = other.Direction;
return true;
}
if ( (other.End - corner).Length <= CornerTolerance )
{
outward = -other.Direction;
return true;
}
outward = default;
return false;
}
static bool Butts( ArchWall wall, ArchWall other, Vector2 corner, Vector2 outward, float thickness, out float reach, out float across )
{
reach = 0f;
across = 0f;
if ( !LandsOn( other, corner, thickness ) )
{
return false;
}
var normal = new Vector2( -other.Direction.y, other.Direction.x );
var closing = Vector2.Dot( outward, normal );
if ( MathF.Abs( closing ) < 0.05f )
{
return false;
}
var half = Half( other, thickness );
var offset = Vector2.Dot( corner - other.Start, normal );
// The face this wall's own travel reaches FIRST, however far behind the authored corner that
// leaves it. A walkway's wall is drawn through the wall it meets to the far face, and settling
// for whichever face is closest kept it there - through the whole wall, cap in the room beyond.
var entering = (-half - offset) / closing;
var leaving = (half - offset) / closing;
var limit = wall.Length * 0.45f;
reach = Math.Clamp( MathF.Min( entering, leaving ), -limit, MaxExtend );
across = Math.Clamp( MathF.Max( entering, leaving ), -limit, MaxExtend );
return true;
}
static bool LandsOn( ArchWall other, Vector2 point, float thickness )
{
var along = Vector2.Dot( point - other.Start, other.Direction );
if ( along < -CornerTolerance || along > other.Length + CornerTolerance )
{
return false;
}
var sideways = point - other.Start;
var distance = MathF.Abs( sideways.x * -other.Direction.y + sideways.y * other.Direction.x );
return distance <= MathF.Max( thickness, CornerTolerance );
}
sealed record LandingStations( List<float> All, List<float> Skirted );
static LandingStations Landings( ArchWall wall, List<ArchWall> neighbours, float thickness )
{
var all = new List<float>();
var skirted = new List<float>();
foreach ( var other in neighbours )
{
if ( ReferenceEquals( other, wall ) || other.Length < 0.5f )
{
continue;
}
var sine = MathF.Abs( wall.Direction.x * other.Direction.y - wall.Direction.y * other.Direction.x );
if ( sine < 0.05f )
{
continue;
}
var half = Half( other, thickness ) / sine;
foreach ( var end in new[] { other.Start, other.End } )
{
if ( (end - wall.Start).Length <= CornerTolerance || (end - wall.End).Length <= CornerTolerance )
{
continue;
}
if ( !LandsOn( wall, end, thickness ) )
{
continue;
}
var along = Vector2.Dot( end - wall.Start, wall.Direction );
all.Add( along - half );
all.Add( along + half );
if ( !other.Baseboard )
{
continue;
}
skirted.Add( along - half );
skirted.Add( along + half );
}
}
return new LandingStations( all, skirted );
}
public static List<ArchOpening> SharedOpenings( ArchWall wall, ArchBuilding building, ArchRoom owner, float thickness )
{
return SharedOpenings( wall, building?.Rooms, owner, thickness );
}
public static List<ArchOpening> SharedOpenings( ArchWall wall, IEnumerable<ArchRoom> rooms, ArchRoom owner, float thickness )
{
var ghosts = new List<ArchOpening>();
var direction = wall.Direction;
foreach ( var room in rooms ?? Array.Empty<ArchRoom>() )
{
if ( MathF.Abs( room.BaseHeight - owner.BaseHeight ) > 0.5f )
{
continue;
}
foreach ( var other in room.Walls )
{
if ( ReferenceEquals( other, wall ) || other.Openings.Count == 0 || other.Length < 0.5f )
{
continue;
}
if ( !Collinear( wall, other, direction, thickness ) )
{
continue;
}
foreach ( var opening in other.Openings )
{
var centre = other.PointAt( opening.Offset );
var along = Vector2.Dot( centre - wall.Start, direction );
if ( along < -opening.Width || along > wall.Length + opening.Width )
{
continue;
}
ghosts.Add( new ArchOpening
{
Id = opening.Id,
Preset = opening.Preset,
Kind = opening.Kind,
Offset = along,
Width = opening.Width,
Height = opening.Height,
SillHeight = opening.SillHeight,
Cased = false,
Leaf = false
} );
}
}
}
return ghosts;
}
public static List<ArchRoom> Breach( ArchPlan plan, ArchBuilding building, ArchRoom wing, ArchKit kit, Vector2 start, Vector2 end, float clear )
{
return Breach( plan, building.Rooms, wing, kit, start, end, clear, wing.Floor );
}
// floor is where the mouth stands: a rising walkway breaches the far wall at the storey it climbs to.
// mayRemoveWall is false for an effect that has to be re-derivable: taking the wall away is not
// something a later rebuild can undo, so a mouth that must follow its owner only ever cuts a hole.
public static List<ArchRoom> Breach( ArchPlan plan, IEnumerable<ArchRoom> rooms, ArchRoom wing, ArchKit kit, Vector2 start, Vector2 end, float clear, int floor, bool mayRemoveWall = true )
{
var span = end - start;
var length = span.Length;
var thickness = kit.WallThickness;
var opened = new List<ArchRoom>();
if ( length < 1f )
{
return opened;
}
var kinds = ArchKinds.Load();
var direction = span / length;
foreach ( var room in rooms.ToList() )
{
if ( ReferenceEquals( room, wing ) || room.Floor != floor )
{
continue;
}
foreach ( var wall in plan.Filed( ArchKind.Wall, room, kinds ).OfType<ArchWall>().ToList() )
{
if ( !Along( wall, start, direction, thickness ) )
{
continue;
}
var a = Vector2.Dot( start - wall.Start, wall.Direction );
var b = Vector2.Dot( end - wall.Start, wall.Direction );
var from = MathF.Max( MathF.Min( a, b ), 0f );
var to = MathF.Min( MathF.Max( a, b ), wall.Length );
if ( to - from < 4f )
{
continue;
}
var height = ArchWallSection.Height( wall, room, kit );
if ( !opened.Contains( room ) )
{
opened.Add( room );
}
if ( mayRemoveWall && clear >= height - 8f )
{
plan.Unfile( wall, kinds );
Stub( plan, kinds, room, wall, 0f, from );
Stub( plan, kinds, room, wall, to, wall.Length );
continue;
}
wall.Openings.Add( new ArchOpening
{
Id = plan.AllocateId(),
// The mouth belongs to what breached it, so losing that closes the hole.
OwnerId = wing.Id,
Preset = "doorway",
Kind = OpeningKind.Archway,
Offset = (from + to) * 0.5f,
Width = to - from,
Height = clear,
Cased = false,
Leaf = false
} );
}
}
return opened;
}
static bool Along( ArchWall wall, Vector2 point, Vector2 direction, float thickness )
{
return wall.Length >= 1f && Aligned( direction, point, wall, thickness );
}
static void Stub( ArchPlan plan, ArchKinds kinds, ArchRoom room, ArchWall source, float from, float to )
{
if ( to - from < 4f )
{
return;
}
var stub = new ArchWall
{
Id = plan.AllocateId(),
Start = source.PointAt( from ),
End = source.PointAt( to ),
Height = source.Height,
Thickness = source.Thickness,
Exterior = source.Exterior,
Baseboard = source.Baseboard,
Cap = source.Cap,
Palette = source.Palette
};
foreach ( var opening in source.Openings.Where( opening => opening.Left >= from && opening.Right <= to ) )
{
stub.Openings.Add( new ArchOpening
{
Id = plan.AllocateId(),
Preset = opening.Preset,
Kind = opening.Kind,
Offset = opening.Offset - from,
Width = opening.Width,
Height = opening.Height,
SillHeight = opening.SillHeight,
Cased = opening.Cased,
Leaf = opening.Leaf,
FlipHinge = opening.FlipHinge
} );
}
plan.File( ArchKind.Wall, room, stub, kinds );
}
static bool Collinear( ArchWall wall, ArchWall other, Vector2 direction, float thickness )
{
return Aligned( direction, wall.Start, other, thickness * 1.5f );
}
// The one collinear test: parallel within a hair, and standing on the same line within the tolerance.
public static bool Aligned( Vector2 direction, Vector2 start, ArchWall other, float tolerance )
{
if ( other.Length < 0.5f || MathF.Abs( Vector2.Dot( direction, other.Direction ) ) < 0.999f )
{
return false;
}
var offset = other.Start - start;
return MathF.Abs( offset.x * -direction.y + offset.y * direction.x ) <= tolerance;
}
public static bool PartyWallExists( ArchBuilding building, float baseHeight, Vector2 start, Vector2 end, float thickness )
{
return PartyWallExists( building?.Rooms, baseHeight, start, end, thickness );
}
public static bool PartyWallExists( IEnumerable<ArchRoom> rooms, float baseHeight, Vector2 start, Vector2 end, float thickness )
{
var span = end - start;
var length = span.Length;
if ( length < 0.5f )
{
return true;
}
var direction = span / length;
foreach ( var room in rooms ?? Array.Empty<ArchRoom>() )
{
if ( MathF.Abs( room.BaseHeight - baseHeight ) > 0.5f )
{
continue;
}
foreach ( var other in room.Walls )
{
if ( other.Length < 0.5f || !Aligned( direction, start, other, thickness ) )
{
continue;
}
var a = Vector2.Dot( other.Start - start, direction );
var b = Vector2.Dot( other.End - start, direction );
var overlap = MathF.Min( MathF.Max( a, b ), length ) - MathF.Max( MathF.Min( a, b ), 0f );
if ( overlap > length * 0.5f )
{
return true;
}
}
}
return false;
}
public static float AngleDegrees( ArchWall wall )
{
var direction = wall.Direction;
return MathF.Atan2( direction.y, direction.x ).RadianToDegree();
}
// A wall whose whole span is claimed by a collinear twin in the room behind it - party wall or
// shared boundary - so only one of them builds. The lower id wins when both are exterior.
public static bool CoveredBy( ArchBuilding building, ArchRoom room, ArchWall wall )
{
if ( wall.Length < 0.5f )
{
return false;
}
var direction = wall.Direction;
foreach ( var other in building.Rooms
.Where( candidate => candidate.Floor == room.Floor && MathF.Abs( candidate.BaseHeight - room.BaseHeight ) < 0.5f && !ReferenceEquals( candidate, room ) )
.SelectMany( candidate => candidate.Walls ) )
{
if ( !Aligned( direction, wall.Start, other, 0.5f ) )
{
continue;
}
var a = Vector2.Dot( other.Start - wall.Start, direction );
var b = Vector2.Dot( other.End - wall.Start, direction );
var from = MathF.Min( a, b );
var to = MathF.Max( a, b );
if ( from > 0.5f || to < wall.Length - 0.5f )
{
continue;
}
if ( other.Exterior != wall.Exterior ? !other.Exterior : other.Id >= wall.Id )
{
continue;
}
return true;
}
return false;
}
}