Editor/Walkway/ArchWalkwayConnection.cs

Editor utility for walkway connections. Computes where a walkway link should cut openings in room walls, opens/closes those openings during rebuilds, groups linked buildings into a site assembly, and manages the walkway deck layer.

File Access
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;

namespace Sunless.Architecture;

// A walkway is a site connection, not a room one of the two buildings happens to own. Its mouths
// are an EFFECT it owns, re-derived from its current footprint on every rebuild - so dragging a
// link re-thinks the join exactly as placing it did, and the group it forms with the houses it
// reaches is the scope those mouths are allowed to cut into.
public static class ArchWalkwayConnection
{
	public const string AnchorA = "AnchorA";
	public const string AnchorB = "AnchorB";

	public static ArchSiteAssembly Form( ArchPlan plan, ArchWalkway.Link link )
	{
		if ( link?.Room is null )
		{
			return null;
		}

		var hosts = link.Endpoints
			.Select( endpoint => endpoint.Building )
			.Where( building => building is not null )
			.Distinct()
			.ToList();

		return Regroup( plan, link.Room, link.Roof, hosts, link.Endpoints.Select( endpoint => endpoint.Room ).ToList() );
	}

	// Every link, every rebuild: close what it opened, cut it again where it stands now, and file it
	// with whatever it currently reaches. A link that reaches nothing keeps nothing open.
	public static int Resolve( ArchPlan plan, ArchKit kit )
	{
		var resolved = 0;

		foreach ( var link in plan.AllRooms().Where( room => room.Spans ).ToList() )
		{
			if ( Recut( plan, kit, link ) )
			{
				resolved++;
			}
		}

		return resolved;
	}

	static bool Recut( ArchPlan plan, ArchKit kit, ArchRoom link )
	{
		var mouths = Mouths( link, kit ).ToList();

		if ( mouths.Count == 0 )
		{
			return false;
		}

		var wanted = Wanted( plan, kit, link, mouths );

		// Compared by where the hole SITS, not just which wall carries it - a link slid along the
		// same wall wants the same wall and a different mouth, and an id check would skip the re-cut.
		var settled = Standing( plan, link ).SetEquals( wanted.Select( Signature ) );

		if ( !settled )
		{
			Close( plan, link );

			foreach ( var cut in wanted )
			{
				cut.Wall.Openings.Add( new ArchOpening
				{
					Id = plan.AllocateId(),
					OwnerId = link.Id,
					Preset = "doorway",
					Kind = OpeningKind.Archway,
					Offset = cut.Offset,
					Width = cut.Width,
					Height = cut.Height,
					Cased = false,
					Leaf = false
				} );
			}
		}

		var rooms = new List<ArchRoom>();
		var hosts = new List<ArchBuilding>();

		foreach ( var cut in wanted )
		{
			if ( !rooms.Contains( cut.Room ) )
			{
				rooms.Add( cut.Room );
			}

			if ( plan.OwnerOf( cut.Room ) is { } host && !hosts.Contains( host ) )
			{
				hosts.Add( host );
			}
		}

		// Always: a link whose cuts are settled can still be sitting in the wrong shape of group,
		// which is what an older plan loaded into the layer editor looks like.
		Regroup( plan, link, ArchWalkway.Deck( plan, link ), hosts, rooms );

		return !settled;
	}

	// The walls each mouth would cut, worked out the same way the placement drag worked them out -
	// one resolution, so a re-cut can never disagree with the cut it replaces.
	static List<Cut> Wanted( ArchPlan plan, ArchKit kit, ArchRoom link, IReadOnlyList<(Vector2 From, Vector2 To)> mouths )
	{
		var merge = ArchLayerGroups.Holding( plan, link.Id )?.Merge ?? new ArchMergeSpec();
		var cuts = new List<Cut>();

		if ( !merge.OpenWalls )
		{
			return cuts;
		}

		var clear = merge.Clearance > 0f ? merge.Clearance : Clear( plan, link, kit );
		var reach = ArchLayerGroups.Reach( plan, link.Id );

		foreach ( var storey in Storeys( plan, link ) )
		{
			foreach ( var room in plan.AllRooms().Where( room => room.Floor == storey && !ReferenceEquals( room, link ) && !room.Spans ) )
			{
				// A group is the scope: outside it the link has nothing it is allowed to open.
				if ( reach is not null && plan.OwnerOf( room ) is { } owner && !reach.Contains( owner.Id ) )
				{
					continue;
				}

				for ( var end = 0; end < mouths.Count; end++ )
				{
					if ( end == 0 ? !merge.NearEnd : !merge.FarEnd )
					{
						continue;
					}

					Meeting( room, kit, mouths[end].From, mouths[end].To, clear, cuts );
				}
			}
		}

		return cuts;
	}

	static void Meeting( ArchRoom room, ArchKit kit, Vector2 start, Vector2 end, float clear, List<Cut> into )
	{
		var span = end - start;
		var length = span.Length;

		if ( length < 1f )
		{
			return;
		}

		var direction = span / length;

		foreach ( var wall in room.Walls )
		{
			if ( wall.Length < 1f || !ArchWallJoins.Aligned( direction, start, wall, kit.WallThickness ) )
			{
				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 );

			into.Add( new Cut
			{
				Room = room,
				Wall = wall,
				Offset = (from + to) * 0.5f,
				Width = to - from,
				// Never the full storey: a hole that took the whole wall would need the wall gone,
				// and a mouth that follows its owner has to leave something to close.
				Height = MathF.Min( clear, height - 8f )
			} );
		}
	}

	// The mouths, read back off the footprint the link stands on now rather than off the drag that
	// first placed it - which is what makes a dragged link re-join whatever it now reaches.
	static IEnumerable<(Vector2 From, Vector2 To)> Mouths( ArchRoom link, ArchKit kit )
	{
		var loop = ArchFloorGen.Footprint( link );

		if ( loop.Count < 3 )
		{
			yield break;
		}

		ArchFootprint.Bounds( loop, out var min, out var max );

		var alongX = max.x - min.x >= max.y - min.y;
		var half = kit.WallThickness * 0.5f;

		if ( alongX )
		{
			yield return (new Vector2( min.x, min.y + half ), new Vector2( min.x, max.y - half ));
			yield return (new Vector2( max.x, max.y - half ), new Vector2( max.x, min.y + half ));
		}
		else
		{
			yield return (new Vector2( max.x - half, min.y ), new Vector2( min.x + half, min.y ));
			yield return (new Vector2( min.x + half, max.y ), new Vector2( max.x - half, max.y ));
		}
	}

	// A flat link meets one storey; a rising one meets the storey it climbs to as well.
	static IEnumerable<int> Storeys( ArchPlan plan, ArchRoom link )
	{
		yield return link.Floor;

		if ( link.WalkwayTop <= link.BaseHeight + 1f )
		{
			yield break;
		}

		var climbed = plan.AllRooms()
			.FirstOrDefault( room => !room.Spans && MathF.Abs( room.BaseHeight - link.WalkwayTop ) < 8f );

		if ( climbed is not null && climbed.Floor != link.Floor )
		{
			yield return climbed.Floor;
		}
	}

	static float Clear( ArchPlan plan, ArchRoom link, ArchKit kit )
	{
		if ( ArchWalkway.Deck( plan, link ) is { } deck && deck.BaseHeight > link.BaseHeight )
		{
			return deck.BaseHeight - link.BaseHeight;
		}

		return ArchFloorGen.WallHeight( link, kit );
	}

	static HashSet<string> Standing( ArchPlan plan, ArchRoom link )
	{
		return plan.AllRooms()
			.SelectMany( room => room.Walls )
			.SelectMany( wall => wall.Openings
				.Where( opening => opening.OwnerId == link.Id )
				.Select( opening => Signature( wall.Id, opening.Offset, opening.Width, opening.Height ) ) )
			.ToHashSet();
	}

	static string Signature( Cut cut ) => Signature( cut.Wall.Id, cut.Offset, cut.Width, cut.Height );

	static string Signature( int wallId, float offset, float width, float height )
	{
		return $"{wallId}:{offset:0.#}:{width:0.#}:{height:0.#}";
	}

	// One group: the houses the link merges, and the link filed last because it is the AFFECTOR - it
	// acts on what sits above it. Its deck rides under the link itself rather than beside it.
	// Reaching nothing dissolves the group, because there is no longer a join to describe.
	static ArchSiteAssembly Regroup( ArchPlan plan, ArchRoom link, ArchRoofPart deck, IReadOnlyList<ArchBuilding> hosts, IReadOnlyList<ArchRoom> anchors )
	{
		var join = ArchLayerGroups.Holding( plan, link.Id );

		// Grouping is automatic, ungrouping is the author's call. A link that stops reaching leaves
		// the group; the group stays, holding the houses, because deleting what someone arranged is
		// not ours to do - the stack has an Ungroup for that.
		if ( hosts.Count == 0 )
		{
			ArchLayerGroups.Leave( plan, link.Id );

			return null;
		}

		join ??= ArchLayerGroups.Create( plan, ArchLayerGroups.Named( plan ), ArchAssemblyKind.Walkway, Array.Empty<int>() );

		var members = hosts.Select( host => host.Id ).Append( link.Id ).ToList();

		foreach ( var member in members )
		{
			ArchLayerGroups.Leave( plan, member );
		}

		join.Children.Clear();
		join.Children.AddRange( members );

		Nest( plan, deck, link );

		ArchLayerGroups.Link( plan, join.Id, AnchorA, anchors.ElementAtOrDefault( 0 )?.Id ?? 0, "Boundary" );
		ArchLayerGroups.Link( plan, join.Id, AnchorB, anchors.ElementAtOrDefault( 1 )?.Id ?? 0, "Boundary" );

		return join;
	}

	// The deck is a roof filed on the host building, which is where the generator wants it. A layer
	// record moves only its ROW, so the stack shows it as part of the link it covers.
	static void Nest( ArchPlan plan, ArchRoofPart deck, ArchRoom link )
	{
		if ( deck is null )
		{
			return;
		}

		ArchLayerGroups.Leave( plan, deck.Id );

		var record = plan.Layers.FirstOrDefault( entry => entry.ItemId == deck.Id );

		if ( record is null )
		{
			record = new ArchLayerRecord { ItemId = deck.Id, Kind = ArchKind.Roof, Stage = ArchLayerStage.Structure };
			plan.Layers.Add( record );
		}

		record.ParentId = link.Id;
	}

	// Whatever a link opened, wherever it stands - closing is the other half of being able to re-cut.
	public static void Close( ArchPlan plan, ArchRoom link )
	{
		foreach ( var wall in plan.AllRooms().SelectMany( room => room.Walls ) )
		{
			wall.Openings.RemoveAll( opening => opening.OwnerId == link.Id );
		}
	}

	// Deleting the link takes its mouths, its deck and its own membership - never the group. Losing an
	// affector is not a reason to throw away the arrangement of what it was affecting.
	public static void Remove( ArchPlan plan, ArchRoom link )
	{
		Close( plan, link );
		Strip( plan, link );
		ArchLayerGroups.Leave( plan, link.Id );
	}

	// The deck is filed on a HOST building, so removing the link from its own room list never reaches it: it
	// stayed standing as a flat roof over the gap with no link under it, orphaned at the top of the stack.
	static void Strip( ArchPlan plan, ArchRoom link )
	{
		if ( ArchWalkway.Deck( plan, link ) is not { } deck )
		{
			return;
		}

		plan.Unfile( deck );

		plan.Layers.RemoveAll( record => record.ItemId == deck.Id );
		ArchLayerGroups.Leave( plan, deck.Id );
	}

	sealed class Cut
	{
		public ArchRoom Room;
		public ArchWall Wall;
		public float Offset;
		public float Width;
		public float Height;
	}
}