Editor/Tool/ArchTool.cs

Editor tool class for architecture editing in the level editor. Manages the plan, kit, archetypes and pillar types, handles placement drafts, commits/banking/undo, regeneration of generated geometry, cursor/grid/wall snapping, selection, deletion and various context menu actions; coordinates scene generation and housekeeping like pruning deleted scene objects.

File AccessNetworking
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

// One plan, one kit, one undo funnel across every tab - a tab is only a choice of subtools and of what its overlay targets.
public abstract partial class ArchTool : EditorTool
{
	const float SettleDelay = 0.25f;

	public static ArchTool Active { get; private set; }

	public ArchPlan Plan { get; private set; } = new();
	public ArchKit Kit { get; private set; } = new();
	public List<ArchArchetype> Archetypes { get; private set; } = new();
	public List<ArchPillarType> PillarTypes { get; private set; } = new();

	public int ActiveBuildingId { get; set; }
	public int ActiveRoomId { get; set; }
	public int Level { get; set; }
	public string ActivePreset { get; set; } = "door";
	public string ActiveWindow { get; set; } = "sash_4over4";
	public ArchSelection Picked { get; set; }

	// Bumped on every commit so read-only viewers (the Plan Layers dock) notice a plan edit.
	public int Revision { get; private set; }

	public bool HideRoofs { get; set; }
	public bool HideWalls { get; set; }
	public bool HideFloors { get; set; }
	public bool HideFoundation { get; set; }
	public bool HideTrim { get; set; }
	public bool OnlyCurrentLevel { get; set; }

	public float StoreyHeight
	{
		get
		{
			var building = ActiveBuilding();

			if ( building is { StoreyHeight: > 1f } )
			{
				return building.StoreyHeight;
			}

			return EnsureKit().WallHeight + EnsureKit().FloorThickness;
		}
	}

	public float LevelHeight => FloorOf( Level );

	// The ceiling over the storey being edited, so a drag made looking up has a plane to land on. The active
	// room answers where there is one, and the kit where there is not. Memoised per edit, because the cursor
	// asks for it several times a frame and finding a recess in it walks the room's cuts.
	public float Overhead()
	{
		if ( overheadTaken )
		{
			return overhead;
		}

		overheadTaken = true;
		overhead = LevelHeight + EnsureKit().WallHeight - ArchFloorGen.CeilingDepth( EnsureKit() );

		if ( ActiveRoom() is { } room && Plan?.OwnerOf( room ) is { } building )
		{
			ArchFootprint.Bounds( ArchFloorGen.Footprint( room ), out var min, out var max );

			overhead = ArchFloorGen.Overhead( Plan, building, room, EnsureKit(), (min + max) * 0.5f );
		}

		return overhead;
	}

	public ArchGridService Grid { get; } = new();

	ArchLayerTree projectedLayers;

	// One projection per edit - the dock and the picker read the same tree Revise invalidates.
	public ArchLayerTree LayerTree
	{
		get
		{
			var tree = projectedLayers ??= ArchLayerTree.Project( Plan );

			// A target that no longer resolves (deleted, reverted) must not keep accepting children.
			if ( InsertionTarget is { } target && tree.Find( target.ItemId ) is null )
			{
				InsertionTarget = null;
			}

			return tree;
		}
	}

	// The parent a newly placed child will be filed under - separate from Picked by design.
	public ArchLayerRef? InsertionTarget { get; set; }

	public ArchLayerRef? SelectedLayer => Picked?.Item is { } item ? LayerTree.Find( item )?.Ref : null;

	// Placements in flight - the tree shows them under their named parent until Finish or Cancel.
	readonly List<ArchDraft> drafts = new();

	public IReadOnlyList<ArchDraft> Drafts => drafts;

	public ArchDraft BeginPlacement( ArchKind kind, ArchLayerRef? parent, object payload, string name = null, object owner = null )
	{
		var draft = new ArchDraft( kind, parent, name ?? $"{ArchKindsAsked.Label( kind )} (draft)", payload, owner );
		drafts.Add( draft );

		return draft;
	}

	// A draft belongs to the subtool that opened it, and only that subtool takes it off again. One left behind -
	// by a tool torn down without its disable, or a shelf raised afresh over the top of it - has nobody to finish
	// or cancel it, so the stack shows a placement in flight that no gesture can ever land.
	void SweepDrafts() => drafts.RemoveAll( draft => draft.Owner is not null && !ReferenceEquals( draft.Owner, CurrentTool ) );

	public void FinishPlacement( ArchDraft draft, int itemId )
	{
		draft.Finish( itemId );
		drafts.Remove( draft );

		FileWithItsGroup( draft, itemId );
		Affect( itemId );
	}

	// A root layer drawn while a group is the target joins it, so the first house you place inside a
	// housing group is inside its scope and everything the group already reaches can cut into it.
	// Only root kinds: a wall belongs to its room, and moving its row into a folder would lose that.
	void FileWithItsGroup( ArchDraft draft, int itemId )
	{
		if ( itemId == 0 || !ArchKindsAsked.RootsAtPlan( draft.Kind ) )
		{
			return;
		}

		if ( ArchLayerGroups.Holding( Plan, itemId ) is not null )
		{
			return;
		}

		var wanted = ArchLayerGroups.Find( Plan, draft.Parent?.ItemId ?? 0 )
			?? ArchLayerGroups.Find( Plan, EditedGroupId );

		ArchLayerGroups.Join( Plan, wanted, itemId );
	}

	public void CancelPlacement( ArchDraft draft )
	{
		draft.Cancel();
		drafts.Remove( draft );
	}

	// The first disable writes the layer's record; the record then owns the enabled state and the
	// next rebuild drops the layer and its effects from generation.
	public void ToggleLayerEnabled( ArchLayerRef layer )
	{
		if ( LayerTree.Find( layer.ItemId ) is not { } node || node.Payload is null )
		{
			return;
		}

		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,
			};

			Plan.Layers.Add( record );
		}

		record.Enabled = !record.Enabled;

		Commit( record.Enabled ? $"Enable {node.Name}" : $"Disable {node.Name}" );
	}

	public ArchKit EnsureKit()
	{
		return Kit ??= new ArchKit();
	}

	// One floor-height answer for overlay, tools and stacked storeys; the plinth lives in GradeLift, not here.
	public float FloorOf( int level ) => level * StoreyHeight + EnsureKit().GroundClearance;

	string committed;

	string pending;
	RealTimeSince touched;

	int standing;

	Ray cursorRay;
	ArchCursor cursorFound;
	int cursorStamp;
	ArchViewAxis cursorAxis;
	float cursorDepth;
	float cursorStanding;
	float cursorReach;
	bool cursorTaken;

	// The surface a gesture is working, held for as long as the gesture lasts. Null is "wherever the ray lands".
	public float? Standing { get; private set; }

	// WHICH surface, not just how high: a snap on a deck may only reach that deck's own walls, or a parapet's
	// end is yanked down onto the corner of a room two storeys under it.
	public ArchRoofPart StandingDeck { get; private set; }

	public void StandOn( float height, ArchRoofPart deck = null )
	{
		Standing = height;
		StandingDeck = deck;
	}

	public void StepOff()
	{
		Standing = null;
		StandingDeck = null;
	}

	// The grid rounds to the nearest rung, which is what pulls a cursor off the corner it was aimed at. A wall
	// snap is the answer to that, so it stands beside the editor's grid rather than under it, and 0 takes a
	// subgrid step off whatever spacing the viewport is drawing.
	public bool SnapsToWalls { get; set; } = true;

	public float WallSnapReach { get; set; }

	public override void OnEnabled()
	{
		Active = this;
		AllowGameObjectSelection = true;

		Plan = ArchStorage.LoadPlan( Scene );
		Kit = ArchStorage.LoadKit( Plan.KitName ) ?? new ArchKit();
		Archetypes = ArchStorage.LoadArchetypes();
		PillarTypes = ArchAsks.PillarTypes();

		Revise();

		committed = ArchStorage.Snapshot( Plan );

		ArchDockPanel.Open();
		ArchLayersDockPanel.Open();
		ArchLayerInspectorPanel.Open();
	}

	// The engine rebuilds the bar on every tool or subtool change, so it holds no state.
	public override Widget CreateToolbarWidget() => new ArchToolbar( this );

	public override void OnDisabled()
	{
		Save();

		if ( Active == this )
		{
			Active = null;
		}
	}

	public override void OnUpdate()
	{
		// Any mesh press is an object pick; Select must hear Gizmo.Pressed to tell a grab from a pick.
		AllowGameObjectSelection = CurrentTool is not ArchSelectSubtool;

		SweepDrafts();
		Settle();
		SettlePreview();
		SyncDeletions();
		DrawPlan();
	}

	// A grid is scaffolding for placing; with Select up there is nothing to place, so it shows none.
	public bool Placing => CurrentTool is ArchSubtool { Placing: true };

	// A plan edit rebuilds the whole scene, so a value typed a character at a time lands once typing stops.
	public void Touch( string action = "Architecture Edit" )
	{
		pending = action;
		touched = 0f;

		Revise();
	}

	void Settle()
	{
		if ( pending is null || touched < SettleDelay )
		{
			return;
		}

		var action = pending;
		pending = null;

		Commit( action );
	}

	// Plan is the source of truth: deleted geometry prunes its entry; no root means clearing geometry, not layout.
	// Only the part COUNT is watched; the compare runs on the frame it drops.
	public void SyncDeletions()
	{
		var root = ArchScene.FindRoot( Scene );

		if ( !root.IsValid() )
		{
			return;
		}

		var counted = Parts( root );
		var lost = counted < standing;

		standing = counted;

		if ( lost )
		{
			Prune( root );
		}
	}

	static int Parts( GameObject root )
	{
		var count = 0;

		foreach ( var building in root.Children )
		{
			count++;

			foreach ( var node in building.Children )
			{
				count += 1 + node.Children.Count;
			}
		}

		return count;
	}

	void Prune( GameObject root )
	{
		var alive = new HashSet<string>();

		foreach ( var building in root.Children )
		{
			if ( building.IsDestroyed )
			{
				continue;
			}

			alive.Add( building.Name );

			foreach ( var node in building.Children )
			{
				if ( node.IsDestroyed )
				{
					continue;
				}

				alive.Add( node.Name );

				foreach ( var part in node.Children )
				{
					if ( !part.IsDestroyed )
					{
						alive.Add( part.Name );
					}
				}
			}
		}

		// An empty entry has no object in the scene to go missing; Commit is what drops it.
		var removed = Plan.Units.RemoveAll( unit => unit switch
		{
			ArchBuilding building => building.HasContent && !alive.Contains( ArchNames.Building( building ) ),
			ArchRoadPart road => !alive.Contains( ArchNames.Road( road ) ),
			_ => false
		} );

		// Stairs first and plan-wide: a flight opens the slabs and walls of every building it walks
		// through, so what survives anywhere decides what the holes everywhere are still for.
		foreach ( var room in Plan.AllRooms() )
		{
			removed += room.Stairs.RemoveAll( stair => !alive.Contains( ArchNames.Stair( stair ) ) );

			// A porch hosts flights too, so the steps off its deck are pruned exactly as the room's own are.
			foreach ( var porch in room.Porches )
			{
				removed += porch.Stairs.RemoveAll( stair => !alive.Contains( ArchNames.Stair( stair ) ) );
			}
		}

		// A hole belongs to a flight, a walkway mouth, a cut, or the wall's own bay rhythm; any owner still
		// standing keeps it open.
		var climbing = Plan.AllRooms()
			.SelectMany( room => room.Stairs.Concat( room.Porches.SelectMany( porch => porch.Stairs ) ) )
			.Select( stair => stair.Id )
			.ToHashSet();
		var owners = climbing
			.Concat( Plan.AllRooms().Where( room => room.Spans ).Select( room => room.Id ) )
			.Concat( Plan.AllCuts().Select( cut => cut.Id ) )
			.Concat( Plan.AllWalls().Select( wall => wall.Id ) )
			.ToHashSet();

		foreach ( var building in Plan.Buildings )
		{
			removed += building.Roofs.RemoveAll( roof => !alive.Contains( ArchNames.Roof( roof ) ) );
			removed += building.Rooms.RemoveAll( room => room.HasContent && !alive.Contains( ArchNames.Room( room ) ) );
			removed += building.Downpipes.RemoveAll( pipe => !alive.Contains( ArchNames.Downpipe( pipe ) ) );
			removed += building.Pipes.RemoveAll( run => !alive.Contains( ArchNames.Pipe( run ) ) );
			removed += building.Brackets.RemoveAll( bracket => !alive.Contains( ArchNames.Bracket( bracket ) ) );
			removed += building.Fences.RemoveAll( fence => !alive.Contains( ArchNames.Fence( fence ) ) );

			foreach ( var roof in building.Roofs )
			{
				removed += roof.Walls.RemoveAll( wall => !alive.Contains( ArchNames.Wall( wall ) ) );
			}

			foreach ( var room in building.Rooms )
			{
				removed += room.Walls.RemoveAll( wall => !alive.Contains( ArchNames.Wall( wall ) ) );
				removed += room.Pillars.RemoveAll( pillar => !alive.Contains( ArchNames.Pillar( pillar ) ) );
				removed += room.Trims.RemoveAll( trim => !alive.Contains( ArchNames.Trim( trim ) ) );
				removed += room.Porches.RemoveAll( porch => !alive.Contains( ArchNames.Porch( porch ) ) );

				foreach ( var porch in room.Porches )
				{
					removed += porch.Pillars.RemoveAll( pillar => !alive.Contains( ArchNames.Pillar( pillar ) ) );
					removed += porch.Trims.RemoveAll( trim => !alive.Contains( ArchNames.Trim( trim ) ) );
				}
				removed += room.Approaches.RemoveAll( approach => !alive.Contains( ArchNames.Approach( approach ) ) );
			}

			// A stairwell exists only for a stair; deleting the flight has to take the shaft or the floor
			// above keeps a dead hole. Plan-wide, because a flight drawn through a party wall opens the
			// slabs of the building it arrives in as well as the one it was filed in.
			removed += building.Cutouts.RemoveAll( cutout => cutout.OwnerId != 0 && !climbing.Contains( cutout.OwnerId ) );

		}

		foreach ( var wall in Plan.AllWalls() )
		{
			removed += wall.Openings.RemoveAll( opening => opening.OwnerId != 0 && !owners.Contains( opening.OwnerId ) );
		}

		if ( removed == 0 )
		{
			return;
		}

		Picked = null;
		Commit( "Delete Architecture" );
	}

	public void Save()
	{
		ArchStorage.SavePlan( Scene, Plan );
	}

	// A hotload keeps this tool and its loaded kit alive, so disk edits land only once something re-reads the kit.
	public ArchKit ReloadKit()
	{
		Kit = ArchStorage.LoadKit( Plan.KitName ) ?? new ArchKit();

		ArchStyle.InvalidateCache();
		Commit();

		return Kit;
	}

	// Same trap as the kit: the live tool survives the hotload that saved the recipe.
	public List<ArchArchetype> ReloadArchetypes()
	{
		Archetypes = ArchStorage.LoadArchetypes();

		return Archetypes;
	}

	public ArchArchetype FindArchetype( string name )
	{
		return Archetypes.FirstOrDefault( archetype => string.Equals( archetype.Name, name, StringComparison.OrdinalIgnoreCase ) );
	}

	// A designed type must be listed the moment it is applied, whether or not it has reached disk.
	public static void Register<T>( List<T> into, T entry, Func<T, string> named )
	{
		if ( into is null || entry is null || named( entry ) is not { Length: > 0 } key )
		{
			return;
		}

		into.RemoveAll( existing => string.Equals( named( existing ), key, StringComparison.OrdinalIgnoreCase ) );
		into.Add( entry );
		into.Sort( ( left, right ) => string.Compare( named( left ), named( right ), StringComparison.OrdinalIgnoreCase ) );
	}

	public void RegisterArchetype( ArchArchetype type ) => Register( Archetypes, type, entry => entry.Name );

	public void RegisterWallPreset( ArchWallPreset preset ) => Register( Kit?.Walls, preset, entry => entry.Name );

	public void RegisterOpeningPreset( ArchOpeningPreset preset ) => Register( Kit?.Openings, preset, entry => entry.Name );

	public List<ArchPillarType> ReloadPillarTypes()
	{
		PillarTypes = ArchAsks.PillarTypes();

		return PillarTypes;
	}

	public ArchPillarType FindPillarType( string name )
	{
		return PillarTypes.FirstOrDefault( type => string.Equals( type.Name, name, StringComparison.OrdinalIgnoreCase ) );
	}

	// A type naming no pillars offers every kind, so a building authored before pillars were data keeps its picker.
	public List<ArchPillarType> OfferedPillars()
	{
		var wanted = FindArchetype( ActiveBuilding()?.Archetype )?.Pillars;

		if ( wanted is null || wanted.Count == 0 )
		{
			return PillarTypes;
		}

		var offered = wanted
			.Select( FindPillarType )
			.Where( type => type is not null )
			.ToList();

		return offered.Count > 0 ? offered : PillarTypes;
	}

	public ArchPillarType DefaultPillar() => OfferedPillars().FirstOrDefault();

	// The type's presets float to the front only - narrowing the list would strand presets the moment a map mixed two types.
	public IEnumerable<ArchOpeningPreset> Offered( Func<ArchOpeningPreset, bool> wanted )
	{
		var matching = Kit.Openings.Where( wanted ).ToList();
		var type = FindArchetype( ActiveBuilding()?.Archetype );

		if ( type is null || type.Openings.Count == 0 )
		{
			return matching;
		}

		return matching
			.OrderBy( preset => type.Openings.FindIndex( name => string.Equals( name, preset.Name, StringComparison.OrdinalIgnoreCase ) ) switch
			{
				< 0 => int.MaxValue,
				var rank => rank
			} )
			.ToList();
	}

	// What Offered floats to the front, said as a yes or no so a browser can file it under Recommended.
	public bool Recommends( ArchOpeningPreset preset )
	{
		var type = FindArchetype( ActiveBuilding()?.Archetype );

		return type is not null
			&& type.Openings.Any( name => string.Equals( name, preset.Name, StringComparison.OrdinalIgnoreCase ) );
	}

	// What the last build left standing, so the next one hands the engine only what moved. Needs no clearing when the
	// plan changes: the key is the geometry's own, so a moved part reads as moved and a lost node reads as missing.
	readonly ArchBuildCache built = new();

	// Outside engine undo scope: Commit owns the only undo entry, at plan level.
	// Revise here too, not only in Commit, to cover a handle drag that edits every frame.
	public void Regenerate( ArchGenerationServices generation = null )
	{
		EnsureKit();
		Revise();

		// Re-baseline the deletion watch: a fresh build is exactly what the plan says, not a delete.
		standing = 0;

		previewPending = false;
		Unbuilt = false;

		// Measured, never assumed: what the next beat may cost is what the last one did, and only the build itself
		// knows whether the gate let one scope through or a whole group.
		RealTimeSince spent = 0f;

		var root = ArchScene.Generate( Scene, Plan, Kit, generation, LayerTree, built );

		previewCost = spent;
		previewed = 0f;

		if ( !root.IsValid() )
		{
			return;
		}

		ArchScene.LinkDoublePairs( root );
		ArchFaceUvMappings.ApplyStored( this );
		ApplyVisibility( root );
	}

	// The one way to ask for a cold build: throw away what we think is standing and emit the lot. The Rebuild
	// button means this - it is what you press when the scene and the plan have stopped agreeing.
	public void RebuildCold()
	{
		Disown();
		Commit( "Rebuild Architecture" );
	}

	// Something has edited the built meshes out from under us - the cull deletes buried faces, the bake merges a
	// room into one mesh. What is standing is no longer what this tool emitted, so the next rebuild has to emit all
	// of it rather than trust a key and leave the polish in place. Both say a rebuild puts it back; this is why.
	public void Disown() => built.Forget();

	public ArchBuildSettlement LastBuild => built.Last;

	// A handle drag edits the plan on every rung it crosses. The plan-side memos are still dropped every frame - the
	// overlay reads them and it is a dictionary clear - but the geometry catches up on a beat, and the edit ghost
	// carries the frames between.
	const float PreviewInterval = 0.08f;

	// The beat is only ever as fast as the build it asks for, and a build is NOT cheap just because the cache spares
	// the engine: an edit inside a group re-runs every generator in it, which measures three quarters of a second on
	// two houses while handing the engine nothing at all. Asked for twelve times a second, that is a drag running a
	// second behind the mouse for as long as it lasts. So the beat is held to a share of what the last one actually
	// COST - a small plan keeps the 80ms, a heavy one drags on the ghost and settles the moment it stops moving.
	const float PreviewDuty = 3f;
	const float PreviewCeiling = 1.5f;

	public static float Beat( float cost ) => MathF.Min( PreviewCeiling, MathF.Max( PreviewInterval, cost * PreviewDuty ) );

	RealTimeSince previewed;
	bool previewPending;
	float previewCost;

	public void Preview()
	{
		Revise();

		if ( previewed < Beat( previewCost ) )
		{
			previewPending = true;

			return;
		}

		Regenerate();
	}

	// A drag that stops moving must still settle, or the geometry sits a rung behind wherever the mouse was let go.
	void SettlePreview()
	{
		if ( previewPending && previewed >= Beat( previewCost ) )
		{
			Regenerate();
		}
	}

	// The one funnel every edit goes through; the undo restores a plan snapshot and rebuilds from it.
	public void Commit( string action = "Architecture Edit" ) => Commit( action, true );

	// The plan written, saved and put on the undo stack with the SCENE left to catch up. Rebuilding is the
	// expensive half of a commit by two orders of magnitude, and a drawing that writes straight through spends
	// one on every stroke - so its edits are banked and a single Build pays for all of them at once.
	public void Bank( string action = "Architecture Edit" ) => Commit( action, false );

	// Edits are standing in the plan that the scene has not been shown. Cleared by the next build, whoever asks
	// for it - a banked edit is not a private one, so anything that rebuilds settles it.
	public bool Unbuilt { get; private set; }

	public void Build()
	{
		Regenerate();
	}

	void Commit( string action, bool geometry )
	{
		EnsureKit();

		// The placement that seeded a target has finished by now, so anything still holding nothing was never drawn.
		Plan.DiscardEmptyTargets();

		Revise();

		// Every affector's effects are an effect it OWNS, so they are re-derived from where it stands
		// NOW - dragging one re-thinks its join exactly as placing it did.
		ArchAffectors.Resolve( Plan, Kit );

		// Resolved once and handed to the rebuild, or Commit would normalize, snapshot, then normalize again.
		var generation = new ArchGenerationServices( Plan, Kit, Scene ).ResolveConnections();

		// What the edit reached: the stack flashes it, and isolate folds away the groups it did not.
		if ( !ConsumeAffected() && SelectedLayer is { } edited )
		{
			Affect( edited.ItemId );
		}

		var before = committed;
		var after = ArchStorage.Snapshot( Plan );

		committed = after;
		Revision++;

		Save();

		if ( geometry )
		{
			Regenerate( generation );
		}
		else
		{
			Unbuilt = true;
		}

		if ( before is null || before == after )
		{
			return;
		}

		SceneEditorSession.Active?.AddUndo( action, () => Rewind( before ), () => Rewind( after ) );
	}

	void Rewind( string snapshot )
	{
		var restored = ArchStorage.Restore( snapshot );

		if ( restored is null )
		{
			return;
		}

		Plan = restored;
		Picked = null;

		Revise();

		committed = ArchStorage.Snapshot( Plan );

		Save();
		Regenerate();
	}

	public ArchBuilding ActiveBuilding()
	{
		return Plan.FindBuilding( ActiveBuildingId ) ?? Plan.Buildings.FirstOrDefault();
	}

	public ArchRoom ActiveRoom()
	{
		var building = ActiveBuilding();

		return building?.Rooms.FirstOrDefault( room => room.Id == ActiveRoomId ) ?? building?.Rooms.FirstOrDefault();
	}

	// The host room must be on the storey the fitting STANDS on: the seeded empty room would file it a storey down. Active room is only the tie-breaker.
	public ArchRoom RoomOnLevel( Vector2 point ) => RoomOnLevel( Plan.AllRooms(), Level, LevelHeight, StoreyHeight, point, ActiveRoom() );

	public static ArchRoom RoomOnLevel( IEnumerable<ArchRoom> rooms, int level, float levelHeight, float storeyHeight, Vector2 point, ArchRoom active )
	{
		// Floor and BaseHeight must agree, or the room hosts whatever it gets on the wrong floor plane.
		var storey = rooms
			.Where( room => room.Floor == level && MathF.Abs( room.BaseHeight - levelHeight ) < storeyHeight * 0.5f )
			.ToList();

		var inside = storey.FirstOrDefault( room => ArchFloorGen.Contains( ArchFloorGen.Footprint( room ), point ) );

		if ( inside is not null )
		{
			return inside;
		}

		return active is not null && storey.Contains( active ) ? active : storey.FirstOrDefault();
	}

	// Outside every footprint a part belongs to the wall it stands against; the active room is a last resort.
	public ArchRoom RoomAt( Vector2 point, out ArchBuilding building )
	{
		var room = Contained( point );

		if ( room is null )
		{
			NearestWall( point, out room, out _, out _ );
		}

		room ??= ActiveRoom();
		building = (room is null ? null : Plan.OwnerOf( room )) ?? ActiveBuilding();

		return room;
	}

	// Not RoomOnLevel: that falls back; this asks whether the cursor is in a room at all.
	public ArchRoom Contained( Vector2 point )
	{
		foreach ( var building in Plan.Buildings )
		{
			if ( Contained( building, point ) is { } room )
			{
				return room;
			}
		}

		return null;
	}

	public ArchRoom Contained( ArchBuilding building, Vector2 point )
	{
		foreach ( var room in building.Rooms )
		{
			if ( room.Floor == Level && ArchFootprint.Contains( Outline( room ), point ) )
			{
				return room;
			}
		}

		return null;
	}

	// Called only where a placement is about to need somewhere to file itself; Commit drops what it left empty.
	public void EnsureTarget()
	{
		if ( Plan.Buildings.Count == 0 )
		{
			Plan.Units.Add( new ArchBuilding { Id = Plan.AllocateId(), Name = "Building" } );
		}

		var building = ActiveBuilding();
		ActiveBuildingId = building.Id;

		if ( building.Rooms.Count == 0 )
		{
			// Seed Floor AND BaseHeight - height alone put the ground-floor room a storey up in the air.
			building.Rooms.Add( new ArchRoom { Id = Plan.AllocateId(), Name = "Room", Floor = Level, BaseHeight = LevelHeight } );
		}

		ActiveRoomId = ActiveRoom()?.Id ?? 0;
	}

	// Drawn at the top of the overlay, above the visibility and action rows every tab shares.
	public virtual void BuildTargets( Layout header, Action refresh ) { }

	// Ridge direction is decided once, on placement, so this turns it without the property sheet.
	public override void BuildSceneContextMenu( Menu menu, Ray ray, SceneTraceResult? trace )
	{
		if ( GroundPoint( ray, out var point ) )
		{
			ArchContextActions.Load().Build( new ArchContextActionContext( this, menu, Level, point ) );
		}

		ApproachMenu( menu, ray );

		if ( Under( ray ) is not { Style: RoofStyle.Gable or RoofStyle.Shed or RoofStyle.Sawtooth } roof )
		{
			return;
		}

		menu.AddOption( $"Turn {roof.Name} ridge", "rotate_90_degrees_cw", () =>
		{
			roof.RidgeAlongX = !roof.RidgeAlongX;
			Commit( "Turn Ridge" );
		} );
	}

	// The placement sidebar cannot reach a ramp that is already down.
	void ApproachMenu( Menu menu, Ray ray )
	{
		if ( ApproachUnder( ray ) is not { } approach )
		{
			return;
		}

		if ( approach.Kind == ApproachKind.Ramp )
		{
			var kerbs = menu.AddOption( $"{approach.Name} kerbs", "align_horizontal_center",
				() => { approach.Kerbs = !approach.Kerbs; Commit( "Ramp Kerbs" ); } );

			kerbs.Checkable = true;
			kerbs.Checked = approach.Kerbs;
		}
		else
		{
			var rails = menu.AddOption( $"{approach.Name} handrails", "fence",
				() => { approach.Rails = !approach.Rails; Commit( "Approach Handrails" ); } );

			rails.Checkable = true;
			rails.Checked = approach.Rails;
		}

		menu.AddSeparator();
	}

	ArchApproachPart ApproachUnder( Ray ray )
	{
		return GroundPoint( ray, out var point ) ? ArchPick.ApproachUnder( Plan, Level, point, out _ ) : null;
	}

	ArchRoofPart Under( Ray ray )
	{
		return GroundPoint( ray, out var point ) ? ArchPick.RoofUnder( Plan, Level, point, out _ ) : null;
	}

	// Memoised against the ray, the terrain stamp (a flatten moves the ground) and the work plane.
	public bool Cursor( out ArchCursor cursor )
	{
		var ray = Gizmo.CurrentRay;
		var plane = ArchWorkPlane.For( this );

		var standing = plane.Standing ?? float.MinValue;

		// The reach is in the key for the same reason the pinned height is: change it and the same ray comes to
		// rest somewhere else, so a memo blind to it answers with yesterday's snap until the mouse moves.
		if ( cursorTaken && cursorStamp == ArchTerrain.Stamped && cursorAxis == plane.Axis && cursorDepth == plane.Depth
			&& cursorStanding == standing && cursorReach == plane.WallReach
			&& ray.Position == cursorRay.Position && ray.Forward == cursorRay.Forward )
		{
			cursor = cursorFound;

			return cursorFound.Found;
		}

		cursorRay = ray;
		cursorStamp = ArchTerrain.Stamped;
		cursorAxis = plane.Axis;
		cursorDepth = plane.Depth;
		cursorStanding = standing;
		cursorReach = plane.WallReach;
		cursorTaken = true;

		plane.Locate( Grid, Scene, ray, out cursorFound );

		cursor = cursorFound;

		return cursorFound.Found;
	}

	public bool GroundPoint( out Vector2 point )
	{
		var found = Cursor( out var cursor );

		point = cursor.Plan;

		return found;
	}

	public bool GroundPoint( Ray ray, out Vector2 point )
	{
		var found = ArchWorkPlane.For( this ).Locate( Grid, Scene, ray, out var cursor );

		point = cursor.Plan;

		return found;
	}

	public Vector2 Snap( Vector2 point )
	{
		return Grid.Base( point );
	}

	// Whether a wall already standing on the surface being worked takes this point ahead of the grid, and where it
	// takes it. A handle and the stair drawing both ask here, or a flight walked onto a wall and the same flight
	// dragged onto it come to rest in two different places - which is the rule ArchWorkPlane.Settle keeps for a
	// point being placed, asked of a point being dragged.
	public bool Walled( Vector2 point, out Vector2 at, int? storey = null, ArchRoofPart deck = null )
	{
		at = point;

		if ( !SnapsToWalls )
		{
			return false;
		}

		var found = ArchWallSnap.Nearest( ArchWallSnap.On( Plan, storey ?? Level, deck ?? StandingDeck ), point,
			ArchWallSnap.Reach( WallSnapReach ) );

		at = found.Point;

		return found.Took;
	}

	public Vector2 Settled( Vector2 point, int? storey = null, ArchRoofPart deck = null )
	{
		return Walled( point, out var at, storey, deck ) ? at : Grid.Base( point );
	}

	// Empty plan with standing geometry: clearing would destroy what no undo could restore, so refuse.
	public void ClearPlan()
	{
		if ( !Plan.HasContent && Generated() )
		{
			Log.Warning( "Architecture: this scene's plan is empty but the scene still holds generated geometry, so clearing could"
				+ " not be undone. Reopen the scene to load its plan, or delete the Generated Architecture object by hand." );

			return;
		}

		Plan = new ArchPlan { KitName = Plan.KitName };
		Picked = null;
		ActiveBuildingId = 0;
		ActiveRoomId = 0;

		Commit( "Clear Architecture Plan" );
	}

	bool Generated()
	{
		var root = ArchScene.FindRoot( Scene );

		return root.IsValid() && root.Components.GetAll<MeshComponent>( FindMode.EverythingInDescendants ).Any();
	}

	// One selection funnel for viewport clicks and the Plan Layers tree; both land in the same scope.
	public void Select( ArchSelection picked )
	{
		Picked = picked;

		(CurrentTool as ArchSubtool)?.Forget();

		if ( picked?.Room is { } room )
		{
			Level = room.Floor;
			ActiveBuildingId = picked.Building?.Id ?? 0;
			ActiveRoomId = room.Id;
		}
		else if ( picked?.Item is ArchBuilding building )
		{
			ActiveBuildingId = building.Id;
		}

		(CurrentTool as ArchSubtool)?.RefreshSidebar();
	}

	// The whole GROUP is what a delete reaches, not the part: everything the deleted layer was reaching into
	// has to be re-thought without it, so the scope it stood in is what gets dirtied - and named, or the
	// commit that follows has no selection left to name and flashes whatever the last edit did.
	public void DeleteSelected()
	{
		if ( Picked is null )
		{
			return;
		}

		var layer = SelectedLayer?.ItemId ?? 0;
		var scope = ArchLayerGroups.Holding( Plan, layer )?.Id ?? layer;

		Picked.RemoveFrom( Plan );
		Picked = null;

		Affect( scope );
		Commit();
	}

	// An opening belongs to its wall's room; the active room alone left the Opening tools dead on the seeded empty room.
	public ArchWall NearestWall( Vector2 point, out ArchRoom room, out float along, out float distance )
	{
		var storey = Plan.Buildings.SelectMany( building => building.Rooms ).Where( entry => entry.Floor == Level );

		return NearestWall( storey, point, out room, out along, out distance );
	}

	public static ArchWall NearestWall( IEnumerable<ArchRoom> rooms, Vector2 point, out ArchRoom room, out float along, out float distance )
	{
		ArchWall best = null;
		room = null;
		along = 0f;
		distance = float.MaxValue;

		foreach ( var candidate in rooms )
		{
			var wall = NearestWall( candidate, point, out var at, out var gap );

			if ( wall is null || gap >= distance )
			{
				continue;
			}

			best = wall;
			room = candidate;
			along = at;
			distance = gap;
		}

		return best;
	}

	public static ArchWall NearestWall( ArchRoom room, Vector2 point, out float along, out float distance )
	{
		ArchWall best = null;
		along = 0f;
		distance = float.MaxValue;

		if ( room is null )
		{
			return null;
		}

		foreach ( var wall in room.Walls )
		{
			var length = wall.Length;

			if ( length < 0.5f )
			{
				continue;
			}

			var projected = Math.Clamp( Vector2.Dot( point - wall.Start, wall.Direction ), 0f, length );
			var closest = wall.PointAt( projected );
			var gap = (point - closest).Length;

			if ( gap >= distance )
			{
				continue;
			}

			distance = gap;
			along = projected;
			best = wall;
		}

		return best;
	}
}