Editor/Bands/ArchWallJoins.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
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; }
// Crossing to the far face of the wall for mouth-jamb mitres
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; }
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;
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 );
}
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;
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 ) );
}
if ( landing is null ) {
var kept = At( atStart, wall.Length, 0f );
return new EndResolution( kept, kept, false, false, false, false, kept );
}
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 );
// First face this wall's travel reaches, not nearest
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 );
}
public static List<ArchRoom> Breach( ArchPlan plan, IEnumerable<ArchRoom> rooms, ArchRoom wing, ArchKit kit, Vector2 start, Vector2 end, float clear, int floor ) {
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 );
}
// A breach this tall leaves no lintel to stand, and the two spaces have to read as one room.
if ( 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(),
// Carried, or an owner's mouth lands on the stub disowned and nothing can ever close it.
OwnerId = opening.OwnerId,
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 );
}
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();
}
// Lower id wins when collinear twins overlap — prevents double-building party walls
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;
}
}