Editor/Tool/ArchTool.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;

namespace Sunless.Architecture;

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

	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 bool ShowBaked { get; set; } = true;

	public float StoreyHeight => ArchBuild.StoreyHeight( ActiveBuilding(), EnsureKit() );

	public float LevelHeight => FloorOf( Level );

	// Memoised per edit — cursor queries it several times a frame.
	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;

	public ArchLayerTree LayerTree {
		get {
			var tree = projectedLayers ??= ArchLayerTree.Project( Plan );

			if ( InsertionTarget is { } target && tree.Find( target.ItemId ) is null ) {
				InsertionTarget = null;
			}

			return tree;
		}
	}

	public ArchLayerRef? InsertionTarget { get; set; }

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

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

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

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

	public void ToggleLayerEnabled( ArchLayerRef layer ) {
		if ( LayerTree.Find( layer.ItemId ) is not { } node || node.Payload is null ) {
			return;
		}

		var enabled = !node.Enabled;

		SetLayerEnabled( layer, enabled );

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

	// No Commit — caller batches and commits once.
	public void SetLayerEnabled( ArchLayerRef layer, bool enabled ) {
		if ( LayerTree.Find( layer.ItemId ) is not { } node || node.Payload is null ) {
			return;
		}

		LayerTree.Record( Plan, node ).Enabled = enabled;
	}

	public void DeleteLayers( IReadOnlyList<ArchLayerNode> nodes ) {
		var wanted = nodes?.Where( node => node?.Payload is not null ).ToList();

		if ( wanted is not { Count: > 0 } ) {
			return;
		}

		var scopes = new List<int>();

		foreach ( var node in wanted ) {
			var layer = node.Ref?.ItemId ?? 0;

			scopes.Add( ArchLayerGroups.Holding( Plan, layer )?.Id ?? layer );

			new ArchSelection { Item = node.Payload, Room = node.Room, Building = node.Building }.RemoveFrom( Plan );
		}

		Picked = null;

		Affect( scopes );
		Touch( wanted.Count > 1 ? $"Delete {wanted.Count} layers" : "Delete" );
	}

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

	public float FloorOf( int level ) => ArchBuild.FloorOf( ActiveBuilding(), EnsureKit(), level );

	string committed;

	string pending;
	RealTimeSince touched;

	int standing;

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

	public float? Standing { get; private set; }
	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;
	}

	public bool SnapsToWalls { get; set; } = true;

	public float WallSnapReach { get; set; }

	public ArchSnapReach SnapReach() {
		if ( !SnapsToWalls ) {
			return ArchSnapReach.None;
		}

		return (CurrentTool as ArchSubtool)?.SnapReach( WallSnapReach ) ?? ArchWallSnap.Reach( WallSnapReach );
	}

	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();
	}

	public override Widget CreateToolbarWidget() => new ArchToolbar( this );

	public override void OnDisabled() {
		Save();

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

	public override void OnUpdate() {
		AllowGameObjectSelection = CurrentTool is not ArchSelectSubtool;

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

	void TurnPicked() {
		if ( !ArchKeys.Pressed( this, KeyCode.R ) || CurrentTool is ArchSubtool subtool && subtool.Turns() ) {
			return;
		}

		if ( Picked?.Item is not ArchBuilding building || !ArchHandles.Pivot( building, out var about ) ) {
			return;
		}

		if ( ArchCarry.Turn( building, about, 90f, Plan ) ) {
			Commit( $"Turn {building.Name}" );
		}
	}

	public bool Placing => CurrentTool is ArchSubtool { Placing: true };

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

	public void SyncDeletions() {
		var root = ArchScene.FindRoot( Scene );

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

		var alive = Parts( root );

		// An EMPTY root is not a scene somebody deleted parts out of - it is one that has not been written yet, or
		// whose whole generated tree went at once. Read as deletions it prunes the entire plan.
		if ( alive.Count == 0 ) {
			standing = 0;

			return;
		}

		var lost = alive.Count < standing;

		standing = alive.Count;

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

	// Porch parts are filed a level deeper than room parts, so the walk is the whole tree, not a fixed depth.
	static HashSet<int> Parts( GameObject root ) {
		var alive = new HashSet<int>();

		foreach ( var node in ArchScene.Descendants( root ) ) {
			if ( ArchNames.TrySourceId( node.Name, out var id ) ) {
				alive.Add( id );
			}
		}

		return alive;
	}

	void Prune( IReadOnlySet<int> alive ) {
		var removed = Plan.Units.RemoveAll( unit => unit switch {
			ArchBuilding building => building.HasContent && !alive.Contains( building.Id ),
			ArchRoadPart road => !alive.Contains( road.Id ),
			_ => false
		} );

		// Stairs are plan-wide — a flight can open slabs across buildings.
		foreach ( var room in Plan.AllRooms() ) {
			removed += room.Stairs.RemoveAll( stair => !alive.Contains( stair.Id ) );

			foreach ( var porch in room.Porches ) {
				removed += porch.Stairs.RemoveAll( stair => !alive.Contains( stair.Id ) );
			}
		}

		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( roof.Id ) );
			removed += building.Rooms.RemoveAll( room => room.HasContent && !alive.Contains( room.Id ) );
			removed += building.Downpipes.RemoveAll( pipe => !alive.Contains( pipe.Id ) );
			removed += building.Pipes.RemoveAll( run => !alive.Contains( run.Id ) );
			removed += building.Brackets.RemoveAll( bracket => !alive.Contains( bracket.Id ) );
			removed += building.Fences.RemoveAll( fence => !alive.Contains( fence.Id ) );

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

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

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

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

	public ArchKit ReloadKit() {
		Kit = ArchStorage.LoadKit( Plan.KitName ) ?? new ArchKit();

		ArchStyle.InvalidateCache();
		Commit();

		return Kit;
	}

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

	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 void RegisterCabinetType( ArchCabinetType type ) => Register( Kit?.Cabinets, type, 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 ) );
	}

	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();

	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();
	}

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

	readonly ArchBuildCache built = new();

	public void Regenerate( ArchBuildContext generation = null, IReadOnlySet<int> rebuild = null ) {
		EnsureKit();
		Revise();

		standing = 0;

		previewPending = false;
		Unbuilt = false;

		RealTimeSince spent = 0f;

		using var profile = ArchBuildProfile.Begin();

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

		previewCost = spent;
		previewed = 0f;

		LastProfile = profile;

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

		using ( ArchBuildProfile.Stage( "Doors" ) ) {
			ArchScene.LinkDoublePairs( root );
		}

		using ( ArchBuildProfile.Stage( "Uv" ) ) {
			ArchFaceUvMappings.ApplyStored( this );
		}

		using ( ArchBuildProfile.Stage( "Visibility" ) ) {
			ApplyVisibility( root );
		}
	}

	public ArchBuildProfile LastProfile { get; private set; }

	public void RebuildCold() {
		Disown();
		Commit( "Rebuild Architecture" );
	}

	// Invalidates the cache so the next rebuild re-emits everything.
	public void Disown() => built.Forget();

	public ArchBuildSettlement LastBuild => built.Last;

	const float PreviewInterval = 0.08f;
	// Preview beat scales with actual build cost so heavy plans don't lag the drag.
	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();
	}

	void SettlePreview() {
		if ( previewPending && previewed >= Beat( previewCost ) ) {
			Regenerate();
		}
	}

	public void Commit( string action = "Architecture Edit" ) => Commit( action, true );

	public void CommitEdited( string action, ArchSelection selection ) => Commit( action, true, null, selection );

	public void CommitPlacement( string action, params ArchBuilding[] buildings ) => Commit( action, true, buildings );

	// Commits the plan without rebuilding geometry — caller builds once when done.
	public void Bank( string action = "Architecture Edit" ) => Commit( action, false );

	public bool Unbuilt { get; private set; }

	public void Build() {
		ArchTerrain.ExcavateBasements( Scene, Plan, EnsureKit() );
		Regenerate();
	}

	void Commit( string action, bool geometry, IReadOnlyList<ArchBuilding> buildings = null, ArchSelection selection = null ) {
		EnsureKit();

		Plan.DiscardEmptyTargets();

		Revise();

		IReadOnlyList<ArchBuilding> excavation = null;
		IReadOnlySet<int> rebuild = null;

		if ( selection is { Item: ArchStairPart stair, Building: { } building } ) {
			var reached = Plan.Buildings
				.Where( host => host == building || host.Cutouts.Any( cutout => cutout.OwnerId == stair.Id ) )
				.ToList();

			ArchStairAffector.Resolve( Plan, Kit, building, selection.Room, stair );

			excavation = reached
				.Concat( Plan.Buildings.Where( host => host.Cutouts.Any( cutout => cutout.OwnerId == stair.Id ) ) )
				.Distinct()
				.ToList();

			rebuild = excavation
				.Select( host => host.Id )
				.Concat( Plan.AllRooms()
					.Where( host => host == selection.Room || host.Walls
						.SelectMany( wall => wall.Openings )
						.Any( opening => opening.OwnerId == stair.Id ) )
					.Select( host => host.Id ) )
				.ToHashSet();
		} else {
			ArchAffectors.Resolve( Plan, Kit );
		}

		if ( geometry ) {
			if ( buildings is { Count: > 0 } ) {
				ArchTerrain.Flatten( Scene, Plan, Kit, buildings );
			}

			if ( excavation is { Count: > 0 } ) {
				ArchTerrain.ExcavateBasements( Scene, Plan, Kit, excavation );
			} else {
				ArchTerrain.ExcavateBasements( Scene, Plan, Kit );
			}
		}

		var generation = new ArchBuildContext( Plan, Kit, Scene ).ResolveConnections();

		if ( !ConsumeAffected() && SelectedLayer is { } edited ) {
			Affect( edited.ItemId );
		}

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

		committed = after;
		Revision++;

		Save();

		if ( geometry ) {
			Regenerate( generation, rebuild );
		} 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();

		ArchTerrain.ExcavateBasements( Scene, Plan, EnsureKit() );
		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();
	}

	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 ) {
		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();
	}

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

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

	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 ) {
			building.Rooms.Add( new ArchRoom { Id = Plan.AllocateId(), Name = "Room", Floor = Level, BaseHeight = LevelHeight } );
		}

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

	public ArchRoom EnsureRoom( ArchBuilding building ) {
		if ( building is null ) {
			return null;
		}

		if ( building.Rooms.FirstOrDefault( standing => standing.Floor == Level && ArchLayerGate.On( standing ) ) is { } held ) {
			return held;
		}

		var room = new ArchRoom { Id = Plan.AllocateId(), Name = "Room", Floor = Level, BaseHeight = LevelHeight };

		building.Rooms.Add( room );

		return room;
	}

	public virtual void BuildTargets( Layout header, Action refresh ) { }

	public override void BuildSceneContextMenu( Menu menu, Ray ray, SceneTraceResult? trace ) {
		if ( AimPoint( 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" );
		} );
	}

	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 AimPoint( ray, out var point ) ? ArchPick.ApproachUnder( Plan, Level, point, out _ ) : null;
	}

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

	public bool Cursor( out ArchCursor cursor ) {
		var ray = Gizmo.CurrentRay;
		var plane = ArchWorkPlane.For( this );

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

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

	// Un-snapped ray hit — for picks and measurements, not placement.
	public bool AimPoint( out Vector2 point ) {
		var found = Cursor( out var cursor );

		point = cursor.Free;

		return found;
	}

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

		point = cursor.Free;

		return found;
	}

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

	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,
			SnapReach() );

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

	public float SeatAt( Vector2 at, ArchRoom room, float? beneath = null, float? upon = null ) {
		var host = Plan.OwnerOf( room ) ?? ActiveBuilding();

		// Use the room's base only if it's on this storey — active room may be a storey up.
		var datum = room is { } standing && standing.Floor == Level
			? standing.BaseHeight
			: ArchBuild.FloorOf( host, EnsureKit(), Level );

		var covered = ArchPillarSoffit.Covering( Plan, Kit, at, datum );
		var ceiling = covered < float.MaxValue
			? MathF.Max( datum + ArchPillarSeat.Clear, covered )
			: datum + ArchPillarSeat.Clear;

		// The aimed-at surface overrides the computed ceiling.
		if ( upon is { } deck ) {
			ceiling = MathF.Max( ceiling, deck + ArchPillarSeat.Clear );
		}

		return ArchPillarSeat.Under( Plan, host, Kit, new ArchGround( Scene ), at, datum,
			beneath is { } aimed ? MathF.Min( ceiling, aimed ) : ceiling );
	}

	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();
	}

	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();
	}

	// Affects the whole group — neighbours must rebuild without the deleted layer.
	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();
	}

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

	// Measured to the wall CENTRELINE; ArchWallSnap.NearestWallFace measures to the near face instead.
	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 < ArchGridService.LeastWallLength ) {
				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;
	}
}