Editor/Layers/ArchLayerTree.cs

Editor-side code that projects an architectural plan into a metadata-only tree for UI. It builds nodes for buildings, rooms, walls and many parts, maintains lookup maps, supports reordering, reparenting, moving payloads between typed lists, computes dirty closures and problems, and exposes stable keys and records to persist overrides.

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

namespace Sunless.Architecture;

// One authored thing in the plan, shown as a tree row. Payload is the typed part itself; virtual
// nodes (story groups) carry none. Building/Room carry the selection context so any row can become
// an ArchSelection without re-hunting ownership.
public sealed class ArchLayerNode
{
	public ArchLayerRef? Ref { get; init; }
	public ArchKind Kind { get; init; }
	public ArchLayerStage Stage { get; init; }
	public ArchLayerDomain Domain { get; init; }
	public object Payload { get; init; }
	public string Name { get; init; } = "";
	// Records may exclude a layer from generation; absent a record, everything is enabled.
	public bool Enabled { get; init; } = true;
	// Locked rows still select and still generate; they refuse every edit.
	public bool Locked { get; init; }
	// Sibling order within a stage, for the kinds that care. Absent a record it is authoring order.
	public int Order { get; init; }
	// Story headers and room layers name the storey they stand on.
	public int Floor { get; init; } = int.MinValue;
	public ArchLayerNode Parent { get; internal set; }
	public List<ArchLayerNode> Children { get; } = new();
	// Selection context: every row knows which building and room it belongs to.
	public ArchBuilding Building { get; init; }
	public ArchRoom Room { get; init; }

	public bool Virtual => Payload is null;

	public string DisplayName => ArchLayerNames.DisplayName( this );
}

public sealed class ArchLayerDomainGroup
{
	public ArchLayerDomain Domain { get; init; }
	public string Name { get; init; } = "";
	public List<ArchLayerNode> Children { get; } = new();
}

// The projected layer tree: a metadata-only view of the plan's authored ownership. Building it must
// never resolve generator shapes - only ids, kinds, names and floors.
public sealed class ArchLayerTree
{
	public List<ArchLayerDomainGroup> Domains { get; } = new();

	readonly Dictionary<int, ArchLayerNode> byId = new();
	readonly Dictionary<object, ArchLayerNode> byPayload = new();
	readonly Dictionary<(int Building, int Floor), ArchLayerNode> stories = new();

	public IReadOnlyList<ArchLayerLink> Links { get; private set; } = Array.Empty<ArchLayerLink>();

	public ArchLayerNode Find( int id ) => byId.TryGetValue( id, out var node ) ? node : null;

	public ArchLayerNode Find( object payload )
	{
		return payload is null ? null : byPayload.TryGetValue( payload, out var node ) ? node : null;
	}

	public object Resolve( ArchLayerRef layer ) => byId.TryGetValue( layer.ItemId, out var node ) ? node.Payload : null;

	// "House 2 / Level 1 / Walkway 1" - the row's authored path, not its object path.
	public string Breadcrumb( ArchLayerNode node )
	{
		if ( node is null )
		{
			return "";
		}

		var parts = new List<string>();

		for ( var current = node; current is not null; current = current.Parent )
		{
			parts.Add( current.DisplayName );
		}

		parts.Reverse();

		return string.Join( " / ", parts );
	}

	// A stable identity for tree widgets: payload rows key by the payload itself (which survives
	// commits), story rows by their building and floor.
	public object StableKey( ArchLayerNode node )
	{
		return node.Payload ?? $"story:{node.Building?.Id}:{node.Floor}";
	}

	// The explicit metadata for a layer, written the first time anything is asked of it that typed
	// ownership cannot answer - a parent, an order, a disabled state, a lock.
	public ArchLayerRecord Record( ArchPlan plan, ArchLayerNode node )
	{
		if ( node?.Ref is not { } layer )
		{
			return null;
		}

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

		if ( record is null )
		{
			record = new ArchLayerRecord
			{
				ItemId = layer.ItemId,
				ParentId = layer.ParentId,
				Kind = layer.Kind,
				Stage = node.Stage,
				Order = node.Order,
				Enabled = node.Enabled,
				Locked = node.Locked,
			};

			plan.Layers.Add( record );
		}

		return record;
	}

	// Drag-reorder writes an explicit order for the whole sibling run, so the arrangement survives a
	// later addition landing at the end of its owner's typed list.
	public bool Reorder( ArchPlan plan, ArchLayerRef moving, int anchorId, bool below )
	{
		if ( !byId.TryGetValue( moving.ItemId, out var node ) || !byId.TryGetValue( anchorId, out var anchor ) )
		{
			return false;
		}

		if ( ReferenceEquals( node, anchor ) || !ReferenceEquals( node.Parent, anchor.Parent ) )
		{
			return false;
		}

		var siblings = (node.Parent?.Children ?? Domains.FirstOrDefault( domain => domain.Domain == node.Domain )?.Children)
			?.Where( child => child.Ref is not null )
			.ToList();

		if ( siblings is null )
		{
			return false;
		}

		siblings.Remove( node );

		var at = siblings.IndexOf( anchor );

		if ( at < 0 )
		{
			return false;
		}

		siblings.Insert( below ? at + 1 : at, node );

		for ( var index = 0; index < siblings.Count; index++ )
		{
			Record( plan, siblings[index] ).Order = index;
		}

		return true;
	}

	// Validated reparent: the capability matrix approves, the payload actually moves between its
	// ownership lists, and a layer record is written so the projection keeps the new parent.
	public bool Reparent( ArchPlan plan, ArchLayerRef layer, int newParentId )
	{
		if ( !byId.TryGetValue( layer.ItemId, out var node ) || node.Payload is null )
		{
			return false;
		}

		// Dropping onto the domain header takes the layer out of whatever group held it.
		if ( newParentId == 0 )
		{
			ArchLayerGroups.Leave( plan, layer.ItemId );

			return true;
		}

		if ( layer.ItemId == newParentId || !byId.TryGetValue( newParentId, out var parentNode ) || parentNode.Payload is null )
		{
			return false;
		}

		// A folder takes anything: membership is a scope, not an ownership claim, so the payload stays
		// exactly where the generator reads it and only the layer it belongs to changes.
		if ( parentNode.Payload is ArchSiteAssembly group )
		{
			ArchLayerGroups.Join( plan, group, layer.ItemId );

			return true;
		}

		if ( !ArchLayerRules.CanParent( parentNode.Kind, layer.Kind ).Allowed )
		{
			return false;
		}

		ArchLayerGroups.Leave( plan, layer.ItemId );

		if ( !MovePayload( plan, node.Payload, parentNode.Payload ) )
		{
			return false;
		}

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

		if ( record is null )
		{
			record = new ArchLayerRecord { ItemId = layer.ItemId, ParentId = newParentId, Kind = layer.Kind, Stage = node.Stage };
			plan.Layers.Add( record );
		}

		record.ParentId = newParentId;

		return true;
	}

	// A wall lives on a room or on a deck, so it is taken out of whichever holds it before being filed anywhere.
	static bool Unfile( ArchPlan plan, ArchWall wall )
	{
		foreach ( var room in plan.AllRooms() )
		{
			if ( room.Walls.Remove( wall ) )
			{
				return true;
			}
		}

		foreach ( var roof in plan.AllRoofs() )
		{
			if ( roof.Walls.Remove( wall ) )
			{
				return true;
			}
		}

		return false;
	}

	// A flight stands in a room, on a platform or on a porch deck, so it is taken out of whichever holds it
	// before being filed anywhere. The same three-homed lookup covers columns and runs.
	static bool Unfile( ArchPlan plan, ArchStairPart stair )
	{
		foreach ( var room in plan.AllRooms() )
		{
			if ( room.Stairs.Remove( stair ) || room.Porches.Any( porch => porch.Stairs.Remove( stair ) ) )
			{
				return true;
			}
		}

		return plan.Buildings.SelectMany( building => building.Platforms ).Any( platform => platform.Stairs.Remove( stair ) );
	}

	static bool Unfile( ArchPlan plan, ArchPillarPart pillar )
	{
		return plan.AllRooms().Any( room => room.Pillars.Remove( pillar ) || room.Porches.Any( porch => porch.Pillars.Remove( pillar ) ) );
	}

	static bool Unfile( ArchPlan plan, ArchTrimPart trim )
	{
		return plan.AllRooms().Any( room => room.Trims.Remove( trim ) || room.Porches.Any( porch => porch.Trims.Remove( trim ) ) );
	}

	// The plan's typed lists are the payloads' real homes - a reparent that only rewrote the record
	// would show a new tree while the generator read the old ownership.
	static bool MovePayload( ArchPlan plan, object payload, object newParent )
	{
		switch ( payload, newParent )
		{
			case (ArchRoom room, ArchBuilding building):
				if ( plan.OwnerOf( room ) is not { } fromRoom ) return false;
				fromRoom.Rooms.Remove( room );
				building.Rooms.Add( room );
				return true;

			case (ArchWall wall, ArchRoom room):
				if ( !Unfile( plan, wall ) ) return false;
				room.Walls.Add( wall );
				return true;

			// Onto a deck: the wall stops standing on a floor and starts standing on a roof, which is the only
			// difference between a partition and a parapet.
			case (ArchWall wall, ArchRoofPart roof):
				if ( !Unfile( plan, wall ) ) return false;
				roof.Walls.Add( wall );
				return true;

			case (ArchOpening opening, ArchWall wall):
				if ( plan.AllWalls().FirstOrDefault( candidate => candidate.Openings.Contains( opening ) ) is not { } fromOpening ) return false;
				fromOpening.Openings.Remove( opening );
				wall.Openings.Add( opening );
				return true;

			case (ArchWallModPart modifier, ArchWall host):
				if ( plan.AllWalls().FirstOrDefault( candidate => candidate.Modifiers.Contains( modifier ) ) is not { } fromModifier ) return false;
				fromModifier.Modifiers.Remove( modifier );
				host.Modifiers.Add( modifier );
				return true;

			case (ArchStairPart stair, ArchRoom room):
				if ( !Unfile( plan, stair ) ) return false;
				room.Stairs.Add( stair );
				return true;

			case (ArchTrimPart trim, ArchRoom room):
				if ( !Unfile( plan, trim ) ) return false;
				room.Trims.Add( trim );
				return true;

			case (ArchPillarPart pillar, ArchRoom room):
				if ( !Unfile( plan, pillar ) ) return false;
				room.Pillars.Add( pillar );
				return true;

			// Onto a porch: the flight stops standing on a floor and starts standing on a deck, which is all
			// that separates an inside stair from the steps off a veranda.
			case (ArchStairPart stair, ArchPorchPart porch):
				if ( !Unfile( plan, stair ) ) return false;
				porch.Stairs.Add( stair );
				return true;

			case (ArchPillarPart pillar, ArchPorchPart porch):
				if ( !Unfile( plan, pillar ) ) return false;
				porch.Pillars.Add( pillar );
				return true;

			case (ArchTrimPart trim, ArchPorchPart porch):
				if ( !Unfile( plan, trim ) ) return false;
				porch.Trims.Add( trim );
				return true;

			// A cut is world-space and stays filed where it was: nesting it under a porch says what it belongs
			// to, the way a walkway's deck record does, and moves nothing the generator reads.
			case (ArchCutPart, ArchPorchPart):
				return true;

			case (ArchBeamPart beam, ArchRoom room):
				if ( plan.AllRooms().FirstOrDefault( candidate => candidate.Beams.Contains( beam ) ) is not { } fromBeam ) return false;
				fromBeam.Beams.Remove( beam );
				room.Beams.Add( beam );
				return true;

			case (ArchPorchPart porch, ArchRoom room):
				if ( plan.AllRooms().FirstOrDefault( candidate => candidate.Porches.Contains( porch ) ) is not { } fromPorch ) return false;
				fromPorch.Porches.Remove( porch );
				room.Porches.Add( porch );
				return true;

			case (ArchApproachPart approach, ArchRoom room):
				if ( plan.AllRooms().FirstOrDefault( candidate => candidate.Approaches.Contains( approach ) ) is not { } fromApproach ) return false;
				fromApproach.Approaches.Remove( approach );
				room.Approaches.Add( approach );
				return true;

			case (ArchRoofPart roof, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Roofs.Contains( roof ) ) is not { } fromRoof ) return false;
				fromRoof.Roofs.Remove( roof );
				building.Roofs.Add( roof );
				return true;

			case (ArchRoofLightPart light, ArchRoofPart roof):
				if ( plan.Buildings.SelectMany( candidate => candidate.Roofs ).FirstOrDefault( candidate => candidate.Lights.Contains( light ) ) is not { } fromLight ) return false;
				fromLight.Lights.Remove( light );
				roof.Lights.Add( light );
				return true;

			case (ArchPlatformPart platform, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Platforms.Contains( platform ) ) is not { } fromPlatform ) return false;
				fromPlatform.Platforms.Remove( platform );
				building.Platforms.Add( platform );
				return true;

			case (ArchStairPart stair, ArchPlatformPart platform):
				if ( !Unfile( plan, stair ) ) return false;
				platform.Stairs.Add( stair );
				return true;

			case (ArchDownpipePart pipe, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Downpipes.Contains( pipe ) ) is not { } fromPipe ) return false;
				fromPipe.Downpipes.Remove( pipe );
				building.Downpipes.Add( pipe );
				return true;

			case (ArchPipePart run, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Pipes.Contains( run ) ) is not { } fromRun ) return false;
				fromRun.Pipes.Remove( run );
				building.Pipes.Add( run );
				return true;

			case (ArchPipeBracketPart bracket, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Brackets.Contains( bracket ) ) is not { } fromBracket ) return false;
				fromBracket.Brackets.Remove( bracket );
				building.Brackets.Add( bracket );
				return true;

			case (ArchFencePart fence, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Fences.Contains( fence ) ) is not { } fromFence ) return false;
				fromFence.Fences.Remove( fence );
				building.Fences.Add( fence );
				return true;

			case (ArchLadderPart ladder, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Ladders.Contains( ladder ) ) is not { } fromLadder ) return false;
				fromLadder.Ladders.Remove( ladder );
				building.Ladders.Add( ladder );
				return true;

			case (ArchBalconyPart balcony, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Balconies.Contains( balcony ) ) is not { } fromBalcony ) return false;
				fromBalcony.Balconies.Remove( balcony );
				building.Balconies.Add( balcony );
				return true;

			case (ArchExteriorStairPart flight, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.ExteriorStairs.Contains( flight ) ) is not { } fromFlight ) return false;
				fromFlight.ExteriorStairs.Remove( flight );
				building.ExteriorStairs.Add( flight );
				return true;

			case (ArchCutPart cut, ArchBuilding building):
				if ( plan.Buildings.FirstOrDefault( candidate => candidate.Cuts.Contains( cut ) ) is not { } fromCut ) return false;
				fromCut.Cuts.Remove( cut );
				building.Cuts.Add( cut );
				return true;

			case (ArchBridgePart bridge, ArchRoadPart road):
				if ( plan.Roads().FirstOrDefault( candidate => candidate.Bridges.Contains( bridge ) ) is not { } fromBridge ) return false;
				fromBridge.Bridges.Remove( bridge );
				road.Bridges.Add( bridge );
				return true;

			case (ArchTunnelPart tunnel, ArchRoadPart road):
				if ( plan.Roads().FirstOrDefault( candidate => candidate.Tunnels.Contains( tunnel ) ) is not { } fromTunnel ) return false;
				fromTunnel.Tunnels.Remove( tunnel );
				road.Tunnels.Add( tunnel );
				return true;

			default:
				return false;
		}
	}

	public bool IsEnabled( object payload )
	{
		return payload is null || byPayload.TryGetValue( payload, out var node ) && node.Enabled;
	}

	public bool IsEnabled( int id )
	{
		return byId.TryGetValue( id, out var node ) && node.Enabled;
	}

	public bool IsLocked( int id )
	{
		return byId.TryGetValue( id, out var node ) && node.Locked;
	}

	public bool IsLocked( object payload )
	{
		return payload is not null && byPayload.TryGetValue( payload, out var node ) && node.Locked;
	}

	// The messages a layer must surface: unresolved required links, missing children.
	public IReadOnlyList<string> Problems( ArchLayerNode node )
	{
		var problems = new List<string>();

		if ( node?.Ref is not { } nodeRef )
		{
			return problems;
		}

		foreach ( var link in Links.Where( link => link.Required && link.SourceId == nodeRef.ItemId ) )
		{
			if ( !byId.ContainsKey( link.TargetId ) )
			{
				problems.Add( $"Required link {link.SourcePort} → {link.TargetPort} (id {link.TargetId}) does not resolve." );
			}
		}

		return problems;
	}

	// The ids a rebuild must touch when this layer changes: itself, its descendants, the hosts that
	// receive its owned effects, and required linked consumers. Generation still rebuilds the whole
	// scene today, but the dirty set is what partial regeneration will later replace.
	public IReadOnlySet<int> DirtyClosure( int itemId )
	{
		var dirty = new HashSet<int> { itemId };

		if ( byId.TryGetValue( itemId, out var node ) )
		{
			CollectDescendants( node, dirty );
		}

		foreach ( var link in Links.Where( link => link.Required && link.TargetId == itemId ) )
		{
			dirty.Add( link.SourceId );
		}

		foreach ( var entry in byId.Values )
		{
			if ( OwnsEffectsOn( entry.Payload, itemId ) && entry.Ref is { } entryRef )
			{
				dirty.Add( entryRef.ItemId );
			}
		}

		return dirty;
	}

	static void CollectDescendants( ArchLayerNode node, HashSet<int> into )
	{
		foreach ( var child in node.Children )
		{
			if ( child.Ref is { } childRef )
			{
				into.Add( childRef.ItemId );
			}

			CollectDescendants( child, into );
		}
	}

	// A wall opening or slab cutout records who made it; disabling that owner has to dirty the host.
	static bool OwnsEffectsOn( object payload, int ownerId )
	{
		return payload switch
		{
			ArchBuilding building => building.Cutouts.Any( cutout => cutout.OwnerId == ownerId ),
			ArchRoom room => room.Walls.SelectMany( wall => wall.Openings ).Any( opening => opening.OwnerId == ownerId ),
			_ => false
		};
	}

	// Builds the tree from typed ownership; explicit records override a payload's parent, kind and
	// stage when present. Old plans carry no records and project unchanged.
	public static ArchLayerTree Project( ArchPlan plan )
	{
		var tree = new ArchLayerTree();

		if ( plan is null )
		{
			return tree;
		}

		var kinds = ArchKinds.Load();
		var entries = new List<Entry>();
		var byId = new Dictionary<int, Entry>();

		void Register( object payload, int id, int parent, ArchKind kind, ArchBuilding building, ArchRoom room,
			int floor = int.MinValue, ArchLayerStage? stage = null )
		{
			var entry = new Entry
			{
				Payload = payload,
				Id = id,
				ParentId = parent,
				Kind = kind,
				Stage = stage,
				Name = kinds.NameOf( kind, payload, id ),
				Building = building,
				Room = room,
				Floor = floor,
			};

			entries.Add( entry );
			byId[id] = entry;
		}

		foreach ( var building in plan.Buildings )
		{
			Register( building, building.Id, 0, ArchKind.Building, building, null );

			foreach ( var room in building.Rooms )
			{
				var kind = room.Spans ? ArchKind.Walkway : ArchKind.Room;
				Register( room, room.Id, building.Id, kind, building, room, room.Floor );

				foreach ( var wall in room.Walls )
				{
					Register( wall, wall.Id, room.Id, ArchKind.Wall, building, room );

					foreach ( var opening in wall.Openings )
					{
						Register( opening, opening.Id, wall.Id, ArchKind.Opening, building, room );
					}

					// The stage is the payload's, not the kind's: a pilaster builds, a recess cuts.
					foreach ( var modifier in wall.Modifiers )
					{
						Register( modifier, modifier.Id, wall.Id, ArchKind.WallMod, building, room,
							stage: modifier.Carves ? ArchLayerStage.Void : ArchLayerStage.Structure );
					}
				}

				foreach ( var stair in room.Stairs )
				{
					Register( stair, stair.Id, room.Id, ArchKind.Stair, building, room );
				}

				foreach ( var trim in room.Trims )
				{
					Register( trim, trim.Id, room.Id, ArchKind.Trim, building, room );
				}

				foreach ( var pillar in room.Pillars )
				{
					Register( pillar, pillar.Id, room.Id, ArchKind.Pillar, building, room );
				}

				foreach ( var span in room.PierSpans )
				{
					Register( span, span.Id, room.Id, ArchKind.Span, building, room );
				}

				foreach ( var beam in room.Beams )
				{
					Register( beam, beam.Id, room.Id, ArchKind.Beam, building, room );
				}

				foreach ( var porch in room.Porches )
				{
					Register( porch, porch.Id, room.Id, ArchKind.Porch, building, room );

					// A porch HOSTS, so its flights, columns and runs are rows under it rather than loose
					// siblings of the room's own - the same shape a walkway's contents take.
					foreach ( var stair in porch.Stairs )
					{
						Register( stair, stair.Id, porch.Id, ArchKind.Stair, building, room );
					}

					foreach ( var pillar in porch.Pillars )
					{
						Register( pillar, pillar.Id, porch.Id, ArchKind.Pillar, building, room );
					}

					foreach ( var trim in porch.Trims )
					{
						Register( trim, trim.Id, porch.Id, ArchKind.Trim, building, room );
					}
				}

				foreach ( var approach in room.Approaches )
				{
					Register( approach, approach.Id, room.Id, ArchKind.Approach, building, room );
				}
			}

			foreach ( var roof in building.Roofs )
			{
				Register( roof, roof.Id, building.Id, ArchKind.Roof, building, null );

				foreach ( var light in roof.Lights )
				{
					Register( light, light.Id, roof.Id, ArchKind.RoofLight, building, null );
				}

				// A wall standing on the deck is a Wall like any other, so it picks, edits and dresses through
				// every path a wall in a room already takes.
				foreach ( var wall in roof.Walls )
				{
					Register( wall, wall.Id, roof.Id, ArchKind.Wall, building, null );

					foreach ( var opening in wall.Openings )
					{
						Register( opening, opening.Id, wall.Id, ArchKind.Opening, building, null );
					}

					foreach ( var modifier in wall.Modifiers )
					{
						Register( modifier, modifier.Id, wall.Id, ArchKind.WallMod, building, null,
							stage: modifier.Carves ? ArchLayerStage.Void : ArchLayerStage.Structure );
					}
				}
			}

			foreach ( var pipe in building.Downpipes )
			{
				Register( pipe, pipe.Id, building.Id, ArchKind.Downpipe, building, null );
			}

			foreach ( var run in building.Pipes )
			{
				Register( run, run.Id, building.Id, ArchKind.Pipe, building, null );
			}

			foreach ( var bracket in building.Brackets )
			{
				Register( bracket, bracket.Id, building.Id, ArchKind.Bracket, building, null );
			}

			foreach ( var fence in building.Fences )
			{
				Register( fence, fence.Id, building.Id, ArchKind.Fence, building, null );
			}

			foreach ( var platform in building.Platforms )
			{
				Register( platform, platform.Id, building.Id, ArchKind.Platform, building, null );

				foreach ( var stair in platform.Stairs )
				{
					Register( stair, stair.Id, platform.Id, ArchKind.CarvedStair, building, null );
				}
			}

			foreach ( var ladder in building.Ladders )
			{
				Register( ladder, ladder.Id, building.Id, ArchKind.Ladder, building, null );
			}

			foreach ( var balcony in building.Balconies )
			{
				Register( balcony, balcony.Id, building.Id, ArchKind.Balcony, building, null );
			}

			foreach ( var flight in building.ExteriorStairs )
			{
				Register( flight, flight.Id, building.Id, ArchKind.ExteriorStair, building, null );
			}

			foreach ( var cut in building.Cuts )
			{
				Register( cut, cut.Id, building.Id, ArchKind.Cut, building, null,
					stage: cut.IsDamage ? ArchLayerStage.Finish : ArchLayerStage.Void );
			}
		}

		foreach ( var road in plan.Roads() )
		{
			Register( road, road.Id, 0, ArchKind.Road, null, null );

			foreach ( var crossing in road.Crossings )
			{
				Register( crossing, crossing.Id, road.Id, ArchKind.Crossing, null, null );
			}

			foreach ( var bridge in road.Bridges )
			{
				Register( bridge, bridge.Id, road.Id, ArchKind.Bridge, null, null );
			}

			foreach ( var tunnel in road.Tunnels )
			{
				Register( tunnel, tunnel.Id, road.Id, ArchKind.Tunnel, null, null );
			}


			foreach ( var cut in road.Cuts )
			{
				Register( cut, cut.Id, road.Id, ArchKind.Cut, null, null,
					stage: cut.IsDamage ? ArchLayerStage.Finish : ArchLayerStage.Void );
			}
		}

		// Explicit metadata overrides ownership defaults; a self-parent record is nonsense and falls back.
		foreach ( var record in plan.Layers )
		{
			if ( byId.TryGetValue( record.ItemId, out var entry ) && record.ParentId != record.ItemId )
			{
				entry.ParentId = record.ParentId;
				entry.Kind = record.Kind;
				entry.Stage = record.Stage;
				entry.Enabled = record.Enabled;
				entry.Locked = record.Locked;
				entry.Order = record.Order;
			}
		}

		var nodes = new Dictionary<int, ArchLayerNode>();

		foreach ( var entry in entries )
		{
			var node = new ArchLayerNode
			{
				Ref = new ArchLayerRef { ItemId = entry.Id, Kind = entry.Kind, ParentId = entry.ParentId },
				Kind = entry.Kind,
				Stage = entry.Stage ?? kinds.Stage( entry.Kind ),
				Domain = kinds.Domain( entry.Kind ),
				Payload = entry.Payload,
				Name = entry.Name,
				Enabled = entry.Enabled,
				Locked = entry.Locked,
				Order = entry.Order,
				Floor = entry.Floor,
				Building = entry.Building,
				Room = entry.Room,
			};

			nodes[entry.Id] = node;
			tree.byId[entry.Id] = node;
			tree.byPayload[entry.Payload] = node;
		}

		// A group is a folder, so its members MOVE into it rather than being listed twice - the whole
		// point of the scope is that a layer stands in exactly one of them. A group sits in the domain
		// its members came from, so grouping two houses does not empty the Buildings branch.
		var memberOf = new Dictionary<int, ArchLayerNode>();

		foreach ( var assembly in plan.Assemblies )
		{
			var node = new ArchLayerNode
			{
				Ref = new ArchLayerRef { ItemId = assembly.Id, Kind = ArchKind.Assembly, ParentId = 0 },
				Kind = ArchKind.Assembly,
				Stage = kinds.Stage( ArchKind.Assembly ),
				Domain = DomainOf( assembly, byId, kinds ),
				Payload = assembly,
				Name = assembly.Name,
			};

			nodes[assembly.Id] = node;
			tree.byId[assembly.Id] = node;
			tree.byPayload[assembly] = node;

			foreach ( var childId in assembly.Children.Where( childId => childId != assembly.Id ) )
			{
				memberOf[childId] = node;
			}
		}

		foreach ( var instance in plan.Instances )
		{
			var node = new ArchLayerNode
			{
				Ref = new ArchLayerRef { ItemId = instance.Id, Kind = ArchKind.AssetInstance, ParentId = 0 },
				Kind = ArchKind.AssetInstance,
				Stage = kinds.Stage( ArchKind.AssetInstance ),
				Domain = ArchLayerDomain.Connections,
				Payload = instance,
				Name = instance.Name,
			};

			nodes[instance.Id] = node;
			tree.byId[instance.Id] = node;
			tree.byPayload[instance] = node;
			tree.AddRoot( node );
		}

		// Story headers exist before any room attaches, so floors sort ascending under their building.
		foreach ( var building in plan.Buildings )
		{
			if ( !nodes.TryGetValue( building.Id, out var buildingNode ) )
			{
				continue;
			}

			foreach ( var floor in entries
				.Where( entry => entry.ParentId == building.Id && (entry.Kind == ArchKind.Room || entry.Kind == ArchKind.Walkway) )
				.Select( entry => entry.Floor )
				.Distinct()
				.OrderBy( floor => floor ) )
			{
				var story = new ArchLayerNode
				{
					Kind = ArchKind.Story,
					Stage = ArchLayerStage.Shape,
					Domain = ArchLayerDomain.Buildings,
					Name = $"Level {floor}",
					Floor = floor,
					Building = building,
				};

				story.Parent = buildingNode;
				buildingNode.Children.Add( story );
				tree.stories[(building.Id, floor)] = story;
			}
		}

		foreach ( var entry in entries )
		{
			var node = nodes[entry.Id];

			// Group membership outranks typed ownership: the walkway leaves the house that filed it.
			if ( memberOf.TryGetValue( entry.Id, out var group ) )
			{
				group.Children.Add( node );
				node.Parent = group;
				continue;
			}

			if ( entry.ParentId == 0 || !nodes.TryGetValue( entry.ParentId, out var parent ) )
			{
				tree.AddRoot( node );
				continue;
			}

			var host = parent;

			if ( parent.Kind == ArchKind.Building
				&& (node.Kind == ArchKind.Room || node.Kind == ArchKind.Walkway)
				&& tree.stories.TryGetValue( (entry.ParentId, entry.Floor), out var story ) )
			{
				host = story;
			}

			host.Children.Add( node );
			node.Parent = host;
		}

		// A group may hold another group: the houses a connector merges gather above it, and the
		// connector is filed beneath the group of what it affects. Nested first and whole, or a
		// group listed before its holder would root itself and then be adopted as well.
		foreach ( var assembly in plan.Assemblies )
		{
			foreach ( var nested in plan.Assemblies.Where( inner => inner.Id != assembly.Id && assembly.Children.Contains( inner.Id ) ) )
			{
				var child = nodes[nested.Id];

				nodes[assembly.Id].Children.Add( child );
				child.Parent = nodes[assembly.Id];
			}
		}

		// An anchor names a layer the group reaches without owning - the far end of a connection.
		// Members already have a row, so only the outside ones are worth stating.
		foreach ( var assembly in plan.Assemblies )
		{
			var node = nodes[assembly.Id];

			// Members sit in the order the group recorded them, which is the shape of the join.
			var member = node.Children.OrderBy( child => Membership( assembly, child ) ).ToList();

			node.Children.Clear();
			node.Children.AddRange( member );

			foreach ( var link in plan.Links.Where( link => link.SourceId == assembly.Id ) )
			{
				tree.byId.TryGetValue( link.TargetId, out var targetNode );

				// An anchor pointing at something already in the group says nothing the rows above it
				// do not - and spelling out its whole path is how a tree turns into a wall of text.
				if ( targetNode is null || Within( targetNode, node ) )
				{
					continue;
				}

				node.Children.Add( new ArchLayerNode
				{
					Kind = ArchKind.Assembly,
					Stage = ArchLayerStage.Reference,
					Domain = node.Domain,
					Payload = new ArchLayerReference { SourcePort = link.SourcePort, TargetId = link.TargetId, TargetPort = link.TargetPort },
					Name = $"{link.SourcePort} → {targetNode.Name}",
					Parent = node,
				} );
			}

			if ( node.Parent is null )
			{
				tree.AddRoot( node );
			}
		}

		foreach ( var group in tree.Domains )
		{
			Sort( group.Children );
		}

		tree.Links = plan.Links;

		return tree;
	}

	// Stage decides evaluation; Order only decides where siblings sit inside their stage, which is
	// what a drag in the stack rearranges.
	static void Sort( List<ArchLayerNode> children )
	{
		if ( children.Count > 1 )
		{
			var ordered = children.OrderBy( child => child.Order ).ToList();

			children.Clear();
			children.AddRange( ordered );
		}

		foreach ( var child in children )
		{
			Sort( child.Children );
		}
	}

	static bool Within( ArchLayerNode node, ArchLayerNode ancestor )
	{
		for ( var current = node; current is not null; current = current.Parent )
		{
			if ( ReferenceEquals( current, ancestor ) )
			{
				return true;
			}
		}

		return false;
	}

	static int Membership( ArchSiteAssembly assembly, ArchLayerNode child )
	{
		var at = child.Ref is { } layer ? assembly.Children.IndexOf( layer.ItemId ) : -1;

		return at < 0 ? int.MaxValue : at;
	}

	// Where the folder sits: with whatever it holds, so a group of houses stays under Buildings.
	static ArchLayerDomain DomainOf( ArchSiteAssembly assembly, Dictionary<int, Entry> byId, ArchKinds kinds )
	{
		foreach ( var childId in assembly.Children )
		{
			if ( byId.TryGetValue( childId, out var entry ) )
			{
				return kinds.Domain( entry.Kind );
			}
		}

		return ArchLayerDomain.Connections;
	}

	void AddRoot( ArchLayerNode node )
	{
		var group = Domains.FirstOrDefault( domain => domain.Domain == node.Domain );

		if ( group is null )
		{
			group = new ArchLayerDomainGroup
			{
				Domain = node.Domain,
				Name = node.Domain switch
				{
					ArchLayerDomain.Buildings => "Buildings",
					ArchLayerDomain.Connections => "Connections",
					_ => "Infrastructure"
				}
			};

			Domains.Add( group );
		}

		group.Children.Add( node );
	}

	sealed class Entry
	{
		public object Payload;
		public int Id;
		public int ParentId;
		public ArchKind Kind;
		public ArchLayerStage? Stage;
		public bool Enabled = true;
		public bool Locked;
		public int Order;
		public string Name;
		public int Floor = int.MinValue;
		public ArchBuilding Building;
		public ArchRoom Room;
	}
}