Editor service for resolving and normalizing architectural connections in a building plan. It finds and merges adjacent walls, resolves wall joins and openings, looks up rooms by storey/position, and caches per-storey lists of rooms and walls.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
public sealed class ArchConnectionService
{
sealed class WallOwner
{
public ArchRoom Room { get; init; }
public ArchWall Wall { get; init; }
}
const float BoundaryTolerance = 1.5f;
readonly ArchPlan plan;
readonly ArchKit kit;
readonly List<ArchRoadJunction> junctions = new();
readonly Dictionary<(int Floor, int Height), IReadOnlyList<ArchRoom>> rooms = new();
readonly Dictionary<(int Floor, int Height), IReadOnlyList<ArchWall>> walls = new();
public IReadOnlyList<ArchRoadJunction> Junctions => junctions;
public ArchPlan Plan => plan;
public ArchKit Kit => kit;
public ArchConnectionService( ArchPlan plan, ArchKit kit )
{
this.plan = plan ?? throw new ArgumentNullException( nameof( plan ) );
this.kit = kit ?? throw new ArgumentNullException( nameof( kit ) );
}
public ArchConnectionService( ArchBuilding building, ArchKit kit )
: this( new ArchPlan { Units = new List<ArchUnit> { building ?? throw new ArgumentNullException( nameof( building ) ) } }, kit )
{
}
public ArchConnectionService ResolveRoads()
{
junctions.Clear();
junctions.AddRange( ArchAnswers.Load().RoadJunctions( plan, kit ) );
return this;
}
public ArchConnectionService NormalizeBoundaries()
{
var kinds = ArchKinds.Load();
foreach ( var building in plan.Buildings )
{
var owners = building.Rooms
.SelectMany( room => room.Walls.Select( wall => new WallOwner { Room = room, Wall = wall } ) )
.ToList();
var changed = true;
while ( changed )
{
changed = false;
for ( var first = 0; first < owners.Count && !changed; first++ )
{
var primary = owners[first];
if ( !plan.Filed( ArchKind.Wall, primary.Room, kinds ).Contains( primary.Wall ) )
{
continue;
}
for ( var second = first + 1; second < owners.Count; second++ )
{
var secondary = owners[second];
if ( !plan.Filed( ArchKind.Wall, secondary.Room, kinds ).Contains( secondary.Wall )
|| primary.Room.Floor != secondary.Room.Floor
|| MathF.Abs( primary.Room.BaseHeight - secondary.Room.BaseHeight ) > 0.5f
|| !CanJoin( primary.Wall, secondary.Wall, kit.WallThickness ) )
{
continue;
}
Merge( primary.Wall, secondary.Wall, kit.WallThickness );
plan.Unfile( secondary.Wall, kinds );
changed = true;
break;
}
}
}
}
rooms.Clear();
walls.Clear();
return this;
}
public IReadOnlyList<ArchRoom> Storey( int level, float baseHeight )
{
var key = StoreyKey( level, baseHeight );
if ( rooms.TryGetValue( key, out var cached ) )
{
return cached;
}
cached = plan.Buildings
.SelectMany( building => building.Rooms )
.Where( room => room.Floor == level && MathF.Abs( room.BaseHeight - baseHeight ) < 0.5f )
.ToList();
rooms[key] = cached;
return cached;
}
public IReadOnlyList<ArchRoom> Storey( int level )
{
return plan.Buildings
.SelectMany( building => building.Rooms )
.Where( room => room.Floor == level )
.ToList();
}
public ArchRoom RoomNear( int level, Vector2 point, float reach = 0f, ArchRoom exclude = null )
{
var rooms = Storey( level )
.Where( room => room.HasFootprint && !ReferenceEquals( room, exclude ) )
.OrderBy( room => room.Spans ? 1 : 0 )
.ThenByDescending( room => room.BaseHeight );
foreach ( var room in rooms )
{
var footprint = ArchFloorGen.Footprint( room );
ArchFootprint.Bounds( footprint, out var min, out var max );
if ( point.x >= min.x && point.x <= max.x && point.y >= min.y && point.y <= max.y )
{
return room;
}
if ( reach > 0f
&& point.x >= min.x - reach && point.x <= max.x + reach
&& point.y >= min.y - reach && point.y <= max.y + reach )
{
return room;
}
}
return null;
}
public ArchWallJoin JoinWall( ArchWall wall, ArchRoom room, float thickness )
{
return Wall( wall, room )
.WithThickness( thickness )
.Create();
}
public ArchWallConnectionBuilder Wall( ArchWall wall, ArchRoom room )
{
return new ArchWallConnectionBuilder( this, wall, room );
}
public IReadOnlyList<ArchOpening> SharedOpenings( ArchWall wall, ArchRoom room, float thickness )
{
return ArchWallJoins.SharedOpenings( wall, Storey( room.Floor, room.BaseHeight ), room, thickness );
}
public ArchRoom RoomAcross( ArchWall wall, ArchRoom room, float offset, float half )
{
var point = wall.PointAt( offset ) - wall.Normal * (half + 1f);
return Storey( room.Floor, room.BaseHeight )
.FirstOrDefault( other =>
!ReferenceEquals( other, room ) &&
other.HasFloor &&
ArchFloorGen.Contains( ArchFloorGen.Footprint( other ), point ) );
}
// HasFloor is not asked - an interior room carries no slab.
public bool Enclosed( ArchWall wall, ArchRoom room, float half )
{
var storey = Storey( room.Floor, room.BaseHeight )
.Where( other => !ReferenceEquals( other, room ) )
.Select( other => ArchFloorGen.Footprint( other ) )
.Where( footprint => footprint.Count >= 3 )
.ToList();
if ( storey.Count == 0 )
{
return false;
}
// All three, or a wall half buried behind a wing loses an outside face it still has.
foreach ( var along in new[] { 0.25f, 0.5f, 0.75f } )
{
var point = wall.PointAt( wall.Length * along ) - wall.Normal * (half + 1f);
if ( !ArchFootprint.Encloses( storey, point ) )
{
return false;
}
}
return true;
}
public ArchRoomConnection OpenRooms( ArchRoom room, ArchBuilding fallback, Vector2 from, Vector2 to, float clear, int floor )
{
var opened = ArchWallJoins.Breach( plan, Storey( floor ), room, kit, from, to, clear, floor );
var owners = opened
.Select( plan.OwnerOf )
.Where( owner => owner is not null )
.Distinct()
.ToList();
InvalidateStorey( room.Floor, room.BaseHeight );
InvalidateStorey( floor, room.BaseHeight );
return new ArchRoomConnection
{
Opened = opened,
Owners = owners,
Host = fallback ?? owners.FirstOrDefault()
};
}
internal ArchWallJoin Resolve( ArchWall wall, ArchRoom room, float thickness )
{
return ArchWallJoins.For( wall, room, WallsOn( room.Floor, room.BaseHeight ), thickness > 0f ? thickness : kit.WallThickness );
}
IReadOnlyList<ArchWall> WallsOn( int floor, float baseHeight )
{
var key = StoreyKey( floor, baseHeight );
if ( walls.TryGetValue( key, out var cached ) )
{
return cached;
}
cached = Storey( floor, baseHeight )
.SelectMany( room => room.Walls )
.ToList();
walls[key] = cached;
return cached;
}
void InvalidateStorey( int floor, float baseHeight )
{
walls.Remove( StoreyKey( floor, baseHeight ) );
}
static bool CanJoin( ArchWall first, ArchWall second, float thickness )
{
if ( first.Length < 0.5f || !ArchWallJoins.Aligned( first.Direction, first.Start, second, MathF.Max( BoundaryTolerance, thickness * 0.25f ) ) )
{
return false;
}
var direction = first.Direction;
var firstEnd = first.Length;
var secondStart = Vector2.Dot( second.Start - first.Start, direction );
var secondEnd = secondStart + Vector2.Dot( second.Direction, direction ) * second.Length;
var secondLow = MathF.Min( secondStart, secondEnd );
var secondHigh = MathF.Max( secondStart, secondEnd );
var gap = MathF.Max( MathF.Max( 0f, secondLow - firstEnd ), MathF.Max( 0f, -secondHigh ) );
return gap <= BoundaryTolerance;
}
static void Merge( ArchWall primary, ArchWall secondary, float kitThickness )
{
var direction = primary.Direction;
var start = primary.Start;
var primaryStart = 0f;
var primaryEnd = primary.Length;
var secondaryStart = Vector2.Dot( secondary.Start - start, direction );
var secondaryEnd = secondaryStart + Vector2.Dot( secondary.Direction, direction ) * secondary.Length;
var from = MathF.Min( primaryStart, MathF.Min( secondaryStart, secondaryEnd ) );
var to = MathF.Max( primaryEnd, MathF.Max( secondaryStart, secondaryEnd ) );
var mergedStart = start + direction * from;
var mergedEnd = start + direction * to;
var openings = primary.Openings
.Concat( secondary.Openings )
.Select( opening => (Opening: opening, Centre: secondary.Openings.Contains( opening ) ? secondary.PointAt( opening.Offset ) : primary.PointAt( opening.Offset ) ) )
.ToList();
primary.Start = mergedStart;
primary.End = mergedEnd;
primary.Height = Maximum( primary.Height, secondary.Height );
primary.Thickness = MathF.Max( Positive( primary.Thickness, kitThickness ), Positive( secondary.Thickness, kitThickness ) );
primary.Exterior &= secondary.Exterior;
primary.Baseboard |= secondary.Baseboard;
primary.Cap &= secondary.Cap;
if ( primary.Cladding == WallCladding.None )
{
primary.Cladding = secondary.Cladding;
}
primary.Wainscot |= secondary.Wainscot;
primary.Openings = openings
.GroupBy( entry => entry.Opening.Id > 0 ? $"id:{entry.Opening.Id}" : $"at:{entry.Centre.x:0.##}:{entry.Centre.y:0.##}:{entry.Opening.Width:0.##}")
.Select( entry =>
{
var opening = entry.First().Opening;
opening.Offset = Vector2.Dot( entry.First().Centre - mergedStart, direction );
return opening;
} )
.OrderBy( opening => opening.Left )
.ToList();
}
static float Positive( float value, float fallback ) => value > 0.01f ? value : fallback;
static float Maximum( float first, float second ) => first > 0.01f || second > 0.01f ? MathF.Max( first, second ) : 0f;
static (int Floor, int Height) StoreyKey( int floor, float baseHeight )
{
return (floor, (int)MathF.Round( baseHeight * 100f ));
}
}