Editor/Services/ArchConnectionService.cs

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.

File Access
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 ));
	}

}