Tools/MapEditor.cs

Editor component for the map authoring tool, handling the tool gun actions and state. It tracks the active tool and many per-tool settings, traces the aim, routes left/right click and console-driven placement to specific Add/Remove methods, measures and builds polygonal blocks (debris/walls/rooms/fog), and updates editor overlays and managers.

File AccessNetworking
using Sandbox;
using System;
using System.Linq;

namespace NZombies;

/// <summary>
/// The tool gun's brain — what happens when a placeable tool is selected and
/// you click.
///
/// One component holds the active tool and routes LMB/RMB to it. Selecting a
/// tool in the Q menu sets ActiveTool; clearing it puts you back to normal.
///
/// ⚠️ PLACE AND REMOVE ARE PUBLIC METHODS, and input only calls them. That is
/// deliberate: nobody can hold a mouse button over MCP, so without a
/// programmatic entry point this feature could not be tested at all until
/// someone was sitting at the machine. Commands drive the same code path the
/// mouse does, so testing it proves the real thing.
/// </summary>
public sealed class MapEditor : Component
{
	/// <summary>
	/// Which placeable is armed. Empty means the tool gun is idle.
	///
	/// ⚠️ CHANGING TOOL THROWS AWAY A HALF-DRAWN FOOTPRINT. Debris and invisible
	/// walls share the corner list, and what gets BUILT is decided by whichever
	/// tool is armed when the last click lands — so clicking three corners with
	/// one tool, switching, and clicking the fourth would silently produce the
	/// other kind of thing. Cheaper to lose three clicks than to place a
	/// permanent wall where a buyable door was meant to go.
	/// </summary>
	[Property]
	public string ActiveTool
	{
		get => _activeTool;
		set
		{
			if ( _activeTool == value ) return;

			_activeTool = value;
			ResetCorners();
		}
	}
	string _activeTool = "";

	/// <summary>How far the tool reaches.</summary>
	[Property] public float Reach { get; set; } = 4096f;

	/// <summary>Removal radius — how close your aim must land to a marker.</summary>
	[Property] public float RemoveRadius { get; set; } = 48f;

	// ── zombie spawn tool settings ───────────────────────────────────────────

	/// <summary>
	/// Flag the next zombie spawn is placed with.
	///
	/// ⚠️ BLANK OR "0" MEANS UNLINKED — eligible from the start. That is the
	/// right default: a map with every spawn linked has nowhere to spawn on
	/// round 1 and the wave stalls immediately.
	///
	/// ⚠️ Normalised in the SETTER, so every route in — the settings panel, a
	/// console command, the component inspector — stores the same thing. "0",
	/// " 0 " and "" are one flag, and only one spelling should ever reach a
	/// saved config.
	/// </summary>
	[Property]
	public string SpawnLink
	{
		get => _spawnLink;
		set => _spawnLink = DoorLinks.Clean( value );
	}
	string _spawnLink = DoorLinks.Unlinked;

	/// <summary>Earliest round the next zombie spawn may be used. 0/1 = always.</summary>
	[Property] public int SpawnActiveRound { get; set; }

	/// <summary>Gate the next zombie spawn on the electricity being on.</summary>
	[Property] public bool SpawnRequiresPower { get; set; }

	/// <summary>Which special the next special spawner will be stamped with.
	/// Stamped at placement like Link and ActiveRound, for the same reason: you
	/// know what belongs here while standing here.</summary>
	[Property] public string SpecialEnemy { get; set; } = SpecialEnemies.Hellhound;

	/// <summary>
	/// Which boss a boss spawner places.
	///
	/// ⚠️ A SECOND FIELD RATHER THAN REUSING `SpecialEnemy`, because the two rosters are
	/// different sets - see `SpecialEnemies.BossNames` / `NonBossNames`. Sharing one field would let
	/// a boss spawner be pointed at a hellhound, or a special spawner at Brutus.
	/// </summary>
	[Property] public string BossEnemy { get; set; } = SpecialEnemies.Brutus;

	// ── debris tool settings ─────────────────────────────────────────────────
	//
	// The barrier tool needs a link and a price per placement, and there is no
	// per-tool settings panel yet. Held here so the Q menu button and the
	// console command place identical barriers, and set with nz_debris_set.

	/// <summary>Flag the next barrier opens. ⚠️ Never blank or "0" — both mean
	/// "already open", so the barrier would gate nothing and buying it would be
	/// a no-op. Normalised in the setter, same as SpawnLink.</summary>
	[Property]
	public string DebrisLink
	{
		get => _debrisLink;
		set => _debrisLink = DoorLinks.Clean( value );
	}
	string _debrisLink = "1";

	/// <summary>Cost of the next barrier. (The original's default price.)</summary>
	[Property] public int DebrisPrice { get; set; } = 1000;

	/// <summary>Gate the next barrier on the electricity. ⚠️ Combined with
	/// Price 0 this means "opens ITSELF when the power comes on", not "free to
	/// buy" — see Debris.RequiresPower.</summary>
	[Property] public bool DebrisRequiresPower { get; set; }

	/// <summary>
	/// Can a player buy the next barrier? ⚠️ Off does NOT make it free or open
	/// — it removes the offer entirely, so the barrier only opens when another
	/// one carrying the same flag is bought. See Debris.Buyable.
	/// </summary>
	[Property] public bool DebrisBuyable { get; set; } = true;

	/// <summary>How to describe the next barrier's cost in a log line — a
	/// non-buyable one has no price worth quoting.</summary>
	string DebrisCostWord => DebrisBuyable ? $"{DebrisPrice} points" : "NOT BUYABLE";

	/// <summary>
	/// Copy the model of whatever you aim at, instead of using a dev box.
	///
	/// ⚠️ This COPIES the model — it does not adopt the object. Adopting a map's
	/// own prop needs an identity that survives save/load, and the bsp's child
	/// GameObjects are regenerated per load, so a stored id would dangle. A copy
	/// looks the same and works on any map.
	/// </summary>
	[Property] public bool DebrisCopyAimedModel { get; set; } = true;

	/// <summary>Fallback barrier model when nothing is copied.</summary>
	[Property] public string DebrisModel { get; set; } = "models/dev/box.vmdl";

	/// <summary>Surface for the next barrier. Empty = untextured.</summary>
	[Property] public string DebrisMaterial { get; set; }
		= "materials/concrete/concretewall052a.vmat";

	/// <summary>
	/// Build barriers as BLOCKS from clicked corners, rather than placing a prop.
	///
	/// The default, because a map needs a barrier shaped like its doorway and a
	/// prop only fits if the map happens to contain one the right shape.
	/// </summary>
	[Property] public bool DebrisBlockMode { get; set; } = true;

	/// <summary>
	/// FALLBACK height only — the height is normally CLICKED.
	///
	/// ⚠️ This used to be the height, typed into the settings panel. It is now
	/// what gets used when there is no click to measure: `nz_debris_build` with
	/// no argument, and a height click that lands below the footprint. Clicking
	/// it is better because a doorway's height is something you can SEE in the
	/// map and cannot easily guess in units.
	/// </summary>
	[Property] public float DebrisHeight { get; set; } = 128f;

	/// <summary>How many corners close a footprint. 4 by default; 2 is enough
	/// for a plain rectangle and is accepted early via nz_debris_build.
	/// ⚠️ The HEIGHT click is not one of these — a 4-corner block takes five
	/// clicks, four on the floor and one at the top.</summary>
	[Property] public int DebrisCorners { get; set; } = 4;

	/// <summary>
	/// Barricade height, in units. 44 is a vault height — see BarricadeSpot.
	///
	/// ⚠️ Settable but deliberately NOT clicked, unlike a debris block's height.
	/// The whole point of a barricade is that it can be climbed, so its height is
	/// a tool SETTING a mapper changes on purpose rather than a value they hit by
	/// aiming a click a bit high.
	/// </summary>
	[Property] public int BarricadeHeight { get; set; } = 44;

	/// <summary>Boards a new barricade starts with. The entity caps at 6.</summary>
	[Property] public int BarricadeBoards { get; set; } = 6;

	/// <summary>Next nav link is usable both ways. Off = A to B only, which is
	/// what a drop wants — see NavLinkSpot.BiDirectional.</summary>
	[Property] public bool NavLinkTwoWay { get; set; }

	/// <summary>Mouth width of the next nav link — also the WIDTH of a drop zone,
	/// i.e. how much of the ledge zombies may go over.</summary>
	[Property] public float NavLinkRadius { get; set; } = 48f;

	/// <summary>Next link allows JUMPING from the LOWER point to the HIGHER one.</summary>
	[Property] public bool NavLinkJump { get; set; } = true;

	/// <summary>Next link allows DROPPING from the HIGHER point to the LOWER one.</summary>
	[Property] public bool NavLinkDrop { get; set; } = true;

	/// <summary>Next link is crossed by WALKING — no jump or drop clip, normal speed, no waiting at
	/// either end. See <see cref="NavLinkSpot.Walk"/>.
	///
	/// ⚠️ OFF BY DEFAULT, because the flag is for links that only exist to join geometry the mesh
	/// would not. A real ledge still wants its jump: turning this on everywhere would have zombies
	/// gliding up walls at walking pace.</summary>
	[Property] public bool NavLinkWalk { get; set; } = false;

	/// <summary>One click makes a DROP ZONE: stand on the ledge, click, and the
	/// landing is found by tracing.
	///
	/// ⚠️ ON BY DEFAULT. Marking a ledge as droppable is the common job by a wide
	/// margin, and it is the one the two-click flow does worst — clicking the
	/// lower end by eye is how you get a link that never connects. Turn it off for
	/// a link whose far end is somewhere a trace would not find, like across a
	/// gap or up onto a roof.</summary>
	[Property] public bool NavLinkDropMode { get; set; } = true;

	/// <summary>Price a newly placed mystery box is authored with.</summary>
	[Property] public int BoxPrice { get; set; } = 950;

	/// <summary>May a newly placed box spot hold the box at round one?</summary>
	[Property] public bool BoxCanStart { get; set; } = true;

	/// <summary>
	/// How far a block extends BELOW its lowest corner, so it always reaches the
	/// walkable surface. Larger than the navmesh agent step (24) on purpose —
	/// see BuildBlock.
	/// </summary>
	public const float Sink = 40f;

	// ── invisible wall tool settings ─────────────────────────────────────────
	//
	// Only two, because the whole point of this tool is that it has none of the
	// others: no price, no flag, no power gate. Height and corner count are
	// shared with the debris tool deliberately — it is the same clicking flow.

	/// <summary>Draw the next barrier, or leave it solid-but-unseen. ⚠️ ON by
	/// default, unlike WallVisible — see Debris.Visible.</summary>
	[Property] public bool DebrisVisible { get; set; } = true;

	/// <summary>Draw the next wall, or leave it invisible. ⚠️ Off by default —
	/// see InvisibleWall.Visible.</summary>
	[Property] public bool WallVisible { get; set; }

	/// <summary>
	/// Stamp the next wall as blocking the horde too. ⚠️ Off by default — see
	/// InvisibleWall.BlocksZombies.
	///
	/// ⚠️ STAMPED AT BUILD, like WallVisible and every other setting on this tool.
	/// Changing it does not alter walls already placed; `nz_wall_zombies &lt;index&gt;` is
	/// what edits an existing one.
	/// </summary>
	[Property] public bool WallBlocksZombies { get; set; }

	// ── Damage wall, stamped at build ────────────────────────────────────────

	/// <summary>Damage dealt to each player inside, per tick.</summary>
	[Property] public float DamageWallDamage { get; set; } = 20f;

	/// <summary>Seconds between ticks.
	///
	/// ⚠️ The two are a PAIR — 20 every 1s and 200 every 10s are not the same wall even
	/// though the rate matches, because the first is survivable at a run and the second
	/// kills anyone unlucky with the phase.</summary>
	[Property] public float DamageWallInterval { get; set; } = 1f;

	/// <summary>Draw it while authoring. Whether a PLAYER sees it is DamageWallShown.</summary>
	[Property] public bool DamageWallVisible { get; set; } = true;

	/// <summary>
	/// Leave it drawn in a round — DamageWall.VisibleInGame.
	///
	/// ⚠️ OFF, like the config field, so nothing about an existing flow changes. Turning it on
	/// is how a hazard you are MEANT to see gets made: a lava pool, a pit of steam, an electric
	/// floor. Left off, the wall is exactly what it has always been — an invisible killbox.
	///
	/// ⚠️ STAMPED AT BUILD, like every other setting on this tool. `nz_dmgwall_show` is what
	/// edits the walls already placed.
	/// </summary>
	[Property] public bool DamageWallShown { get; set; }

	/// <summary>
	/// Surface for the next damage wall. Used whenever it is drawn at all.
	///
	/// ⛔ ITS OWN FIELD, NOT `WallMaterial`. That one belongs to the invisible-wall tool and
	/// its list is brick and concrete — the surfaces a boundary wants. A damage wall wants the
	/// opposite: something that announces itself. Sharing one field would mean every switch
	/// between the two tools silently retextured the other.
	/// </summary>
	[Property] public string DamageWallMaterial { get; set; } = "materials/nz/lava.vmat";

	/// <summary>Surface for the next wall. Only used when it is visible.</summary>
	[Property] public string WallMaterial { get; set; }
		= "materials/concrete/concretewall052a.vmat";

	// ── Light, stamped at build ───────────────────────────────────────────

	/// <summary>Colour of the next light, as hex. The panel's swatch row writes this.</summary>
	[Property] public string LightColor { get; set; } = "#ffeeb8";

	/// <summary>Multiplier on that colour — a swatch can pick a hue but not a level.</summary>
	[Property] public float LightBrightness { get; set; } = 1f;

	/// <summary>How far it reaches.</summary>
	[Property] public float LightRadius { get; set; } = 400f;

	/// <summary>⛔ OFF BY DEFAULT. Shadows are the single biggest cost in this engine — see
	/// `LightBake` and the 3.5x fps swing canyon_labs paid for 71 of them.</summary>
	[Property] public bool LightShadows { get; set; }

	/// <summary>Only lit once the power is on.</summary>
	[Property] public bool LightRequiresPower { get; set; }

	// ── fog area ─────────────────────────────────────────────────────────────

	/// <summary>Soot is a warm near-black. A neutral grey reads as mist, not ash.</summary>
	[Property] public string FogColor { get; set; } = "#2a2320";

	/// <summary>
	/// 0 to 1, and it wants to stay low.
	///
	/// ⚠️ THE NUMBER THAT LOOKS TOO SMALL IS THE RIGHT ONE. Fog reads as atmosphere well below
	/// where it reads as a value worth typing — 0.18 is already visible across a room, 0.5 is a
	/// smoke grenade. "Something light" was the request, and this default is under what feels right.
	/// </summary>
	[Property] public float FogDensity { get; set; } = 0.18f;

	/// <summary>
	/// How far inside the drawn boundary the haze reaches full strength, in units.
	///
	/// ⚠️ A DISTANCE, NOT A FRACTION — the shape is a drawn polygon now, and a fraction of it would
	/// mean a 40-unit fade in a doorway and a 900-unit one in a hall. The height comes from the
	/// second click, so there is no separate height setting any more.
	/// </summary>
	[Property] public float FogFeather { get; set; } = 200f;

	// ── room zone ────────────────────────────────────────────────────────────

	/// <summary>
	/// The name stamped on the next room zone — what the top left says once a player walks into it (`RoomNames`). A zone
	/// already drawn is renamed in Settings → Map → Room names, or with `nz_room_zone_name`.
	/// </summary>
	[Property] public string RoomZoneName { get; set; } = "";

	// ── Sound, stamped at build ──────────────────────────────────────────

	/// <summary>The `.sound` EVENT for the next sound — not an audio file. See SoundSpot.</summary>
	[Property] public string SoundEventPath { get; set; } = "";

	[Property] public float SoundSpotVolume { get; set; } = 1f;

	[Property] public float SoundSpotDistance { get; set; } = 1000f;

	/// <summary>Seconds between re-triggers for the next sound. 0 plays it once.
	///
	/// ⚠️ MATCH THE CLIP'S LENGTH — `SoundEvent` exposes no duration, so nothing can work it out
	/// for you. The editor logs it on import.</summary>
	[Property] public float SoundSpotRepeat { get; set; } = 20f;

	// ── Springboard, stamped at build — and onto one clicked inside (AddSpringboardAt) ──────────────────────

	float? _springStrength, _springRadius;
	bool? _springSafe;

	/// <summary>
	/// Upward speed of the next springboard, in units per second. 700, the perk's; see SpringboardSpot.Strength.
	///
	/// ⚠️ NULLABLE-BACKED, as the statics here are (INSTRUCTIONS §1): a hotload can bring these to an editor that already
	/// exists without running an initialiser, and `= 700f` would then read 0, a pad that throws nobody.
	/// </summary>
	[Property] public float SpringboardStrength { get => _springStrength ?? 700f; set => _springStrength = value; }

	/// <summary>How wide the next springboard is, flat, in units. 48, the perk's.</summary>
	[Property] public float SpringboardRadius { get => _springRadius ?? 48f; set => _springRadius = value; }

	/// <summary>No fall damage on the landing after the next springboard's throw. On.</summary>
	[Property] public bool SpringboardSafeLanding { get => _springSafe ?? true; set => _springSafe = value; }

	/// <summary>Corners clicked so far. Cleared when the block is built or reset.</summary>
	readonly System.Collections.Generic.List<Vector3> _corners = new();

	/// <summary>
	/// The footprint is closed and the NEXT click sets the height.
	///
	/// ⚠️ A mode, not a number, because the same mouse button now means two
	/// different things — another corner, or the top of the wall. The overlay
	/// says which one it is currently listening for; without that, a click that
	/// "did nothing" and a click that silently built a barrier look the same.
	/// </summary>
	bool _awaitingHeight;

	/// <summary>True while the tool wants a height click rather than a corner.</summary>
	public bool AwaitingHeight => _awaitingHeight;

	public int PendingCorners => _corners.Count;

	public bool HasTool => !string.IsNullOrEmpty( ActiveTool );

	protected override void OnUpdate()
	{
		// ⚠️ BEFORE EVERY RETURN: the markers' countdown must see every frame the overlay does, or the two drift apart and
		// the markers flicker (`MarkerLife`).
		_markerLife -= Time.Delta;

		// ⚠️ Only in creative. Placing spawns mid-round would be chaos, and the
		// markers are creative-only anyway.
		if ( !NZGame.IsCreative ) return;

		// ⛔ ONLY THE INPUT IS GATED ON HAVING A TOOL — THE MARKERS ARE NOT.
		//
		// `if ( !HasTool ) return;` used to sit at the top of this method, which
		// meant walking into creative showed an EMPTY MAP: every spawn, barrier
		// and switch in the config was invisible until you happened to arm a tool.
		// Reported as "otherwise theres nothing placed until i select a tool" —
		// and it reads as a lost config, not as a hidden overlay.
		//
		// Holstering is not a request to hide what is already placed. The setting
		// for that is PreviewMode, which exists precisely so it can be ASKED for.
		if ( HasTool )
		{
			// In block mode LMB collects corners instead of placing immediately —
			// the same "LMB corners · R reset" flow the original's wall tools use.
			if ( Input.Pressed( "Attack1" ) )
			{
				// ⚠️ Clicking an EXISTING barrier edits it rather than building
				// another. Without this, retagging a door meant deleting and
				// rebuilding it — losing a footprint you had clicked out by hand
				// just to change a price.
				if ( ActiveTool == NZTools.Debris && ApplyToAimedDebris() ) { }
				else if ( ActiveTool == NZTools.Debris && DebrisBlockMode )
				{
					// Say what was under the crosshair when we chose to build
					// instead of edit. "It made a new wall" and "it did not
					// recognise the wall I was pointing at" look identical from the
					// outside and are completely different faults.
					if ( DebrisDebug ) ReportAimedForEdit();
					AddCorner();
				}

				// ⚠️ ALWAYS CORNERS — an invisible wall has no prop mode. There is
				// no such thing as copying a map prop's model for something whose
				// default state is not being drawn at all.
				else if ( ActiveTool == NZTools.InvisibleWall ) AddCorner();
				else if ( ActiveTool == NZTools.DamageWall ) AddCorner();
				else if ( ActiveTool == NZTools.RoomZone ) AddCorner();

				// ⚠️ TWO POINTS, NO HEIGHT CLICK. A barricade is a LINE, and its
				// height is fixed on purpose — see BarricadeSpot.Height. Sharing
				// AddCorner would drag in the four-corner count and the height
				// prompt, neither of which applies.
				else if ( ActiveTool == NZTools.Barricade ) AddBarricadePoint();
				else if ( ActiveTool == NZTools.NavLink ) AddNavLinkPoint();

				// ⚠️ ONE CLICK. A box is an object you stand at, not a span you
				// build — so it goes through Place() like a spawn marker rather
				// than through the corner flow.
				else if ( ActiveTool == NZTools.MysteryBox ) AddMysteryBox();
				else if ( ActiveTool == NZTools.PackAPunch ) AddPackAPunch();
				else if ( ActiveTool == NZTools.Wunderfizz ) AddWunderfizz();
				else if ( ActiveTool == NZTools.PerkMachine ) AddPerkMachine();
				else if ( ActiveTool == NZTools.Teleporter ) AddTeleporterPoint();
				else if ( ActiveTool == NZTools.Springboard ) AddSpringboard();
				else if ( ActiveTool == NZTools.MapLight ) AddMapLight();
				else if ( ActiveTool == NZTools.FogArea ) AddCorner();
				else if ( ActiveTool == NZTools.SoundSpot ) AddSoundSpot();
				else if ( ActiveTool == NZTools.SoulBox ) AddSoulBox();
				else if ( ActiveTool == NZTools.AmmoBox ) AddAmmoBox();
				else if ( ActiveTool == NZTools.BuyableEnding ) AddEnding();
				else if ( ActiveTool == NZTools.Misery ) AddMisery();
				else if ( ActiveTool == NZTools.Clue ) AddClue();
				else if ( ActiveTool == NZTools.Pressable ) AddPressable();
				else if ( ActiveTool == NZTools.Shootable ) AddShootable();
				else if ( ActiveTool == NZTools.TradeTable ) AddTradeTable();
				else if ( ActiveTool == NZTools.BuildTable ) AddBuildTable();
				else if ( ActiveTool == NZTools.BuildPart ) AddBuildPart();
				else if ( ActiveTool == NZTools.Arsenal ) AddArsenal();

				else Place();
			}

			if ( Input.Pressed( "Attack2" ) ) RemoveAimed();
			if ( Input.Pressed( "Reload" ) ) ResetCorners();
		}

		// Placing still works in preview — only the overlays stop drawing.
		// ⛔ TEN TIMES A SECOND, NOT EVERY FRAME — see `MarkerLife`. Hidden, the next showing draws at once.
		if ( !NZGame.ShowAuthoringVisuals ) _markerLife = -1f;
		else if ( _markerLife < 0f )
		{
			DrawMarkers();
			_markerLife = MarkerLife;
		}

		// ⛔ DRAWN HERE, NOT AS THE LAST LINE OF DrawDebris, WHICH IS WHERE IT
		// USED TO LIVE. That put the corners you are CURRENTLY CLICKING at the
		// end of the pass that draws ALREADY-PLACED barriers — six unrelated draw
		// calls deep, behind a config lookup and a per-item loop. Anything that
		// stopped that pass early took your in-progress footprint with it, which
		// is what "sometimes the orange nodes stop being visible" was.
		//
		// Pending corners are TOOL STATE, not a marker for something placed. They
		// belong on their own line where nothing else can gate them.
		//
		// ⚠️ And NOT under ShowAuthoringVisuals either. Preview mode hides placed
		// markers so the map can be judged as a player sees it — but a footprint
		// you have half-clicked is unsaved work, and hiding it makes it look
		// thrown away. Toggling preview mid-build must not cost you four clicks.
		// ⚠️ …EXCEPT IN PHOTO MODE (`nz_photo`), which is the map alone — the corners come back with it
		if ( Scene.DebugOverlay is not null && !MapPhotoMode.On )
			DrawPendingCorners( Scene.DebugOverlay );
	}

	/// <summary>
	/// The editor component, creating it on the player if there is none.
	///
	/// ⛔ THE MARKERS CANNOT DRAW FROM A COMPONENT THAT DOES NOT EXIST. This used
	/// to be created lazily by whatever touched it first — which in practice was
	/// arming a tool from the Q menu — so a fresh creative session had no
	/// MapEditor at all and therefore no overlay, no matter what the drawing code
	/// did. Same shape as DebrisManager.Ensure, and for the same reason.
	/// </summary>
	public static MapEditor Ensure()
	{
		var p = NZPlayer.Local;
		return p?.Components.GetOrCreate<MapEditor>();
	}

	// ── placing ──────────────────────────────────────────────────────────────

	/// <summary>
	/// Where the tool is pointing, or null if it hit nothing.
	///
	/// ⚠️ <paramref name="quiet"/> exists for the PER-FRAME callers. The height
	/// preview traces every frame while the tool waits for a height click, and
	/// the miss branch logs — pointing at the sky for two seconds would put a
	/// hundred identical lines in the console and bury whatever came before.
	/// A miss is only worth reporting when a click asked for something.
	/// </summary>
	public SceneTraceResult? AimTrace( bool quiet = false )
	{
		var cam = Scene.Camera;

		// ⚠️ Distinguish the two failures. Both used to log "aiming at nothing",
		// which is misleading — a missing camera and a ray into the sky need
		// completely different fixes, and one of them is a bug.
		if ( !cam.IsValid() )
		{
			if ( !quiet ) Log.Warning( "[nz] tool: no active camera — cannot aim" );
			return null;
		}

		var from = cam.WorldPosition;
		var to = from + cam.WorldRotation.Forward * Reach;

		var tr = Scene.Trace.Ray( from, to )
			.IgnoreGameObjectHierarchy( GameObject )
			.Run();

		if ( !tr.Hit )
		{
			if ( !quiet )
				Log.Info( $"[nz] tool: ray from {from} hit nothing within {Reach}" );
			return null;
		}

		return tr;
	}

	/// <summary>
	/// Place the active tool's object where you are aiming.
	///
	/// ⚠️ Yaw faces the PLACER, not away. Stand in a room, point at the far
	/// wall, and someone spawning there looks back into the room rather than at
	/// the wall — which is what you want and the opposite of the obvious
	/// implementation.
	/// </summary>
	public bool Place()
	{
		var hit = AimTrace();
		if ( hit is null ) { Log.Info( "[nz] tool: aiming at nothing" ); return false; }

		// Remember what is under the crosshair so the debris tool can copy its
		// model. Only meaningful for an AIMED placement — an offset placement
		// has no crosshair target, so this stays null and the fallback is used.
		_aimedModel = ModelNameOf( hit.Value.GameObject );

		// ⚠️ THE NORMAL GOES WITH THE POINT. Without it a tool can only know
		// WHERE you clicked, never WHAT you clicked on — and "lie flat against
		// that surface" is a question only the normal can answer.
		var placed = PlaceAt( hit.Value.HitPosition, hit.Value.Normal );
		_aimedModel = null;
		return placed;
	}

	/// <summary>Model of the thing under the crosshair during the current
	/// Place() call. Null when placing by offset.</summary>
	string _aimedModel;

	static string ModelNameOf( GameObject go )
	{
		var r = go?.Components.Get<ModelRenderer>( FindMode.EverythingInSelfAndAncestors );
		return r.IsValid() && r.Model is not null && !string.IsNullOrWhiteSpace( r.Model.Name )
			? r.Model.Name
			: null;
	}

	/// <summary>
	/// Place at a specific point: on the clicked surface when that is a floor, else dropped to the floor below.
	///
	/// ⚠️ Anything not on a floor is floor-traced. A point picked in mid-air — from a capped reach,
	/// or a ray that clipped a railing — would otherwise become a spawn point
	/// hanging in space, which is worse than no spawn point at all because it
	/// looks placed.
	/// </summary>
	public bool PlaceAt( Vector3 point, Vector3? normal = null )
	{
		// ⛔ ON THE SURFACE THAT WAS CLICKED. The drop to the floor used to start 96 units OVER the click, so a click on a
		// floor under a table, a shelf, a stair or a low ceiling landed ON TOP of that instead — or in mid-air, when the
		// trace began inside a ceiling slab (the user, 2026-10-01: "sometimes it appears in the surface above it instead of
		// the surface i clicked"). A floor-like surface (a floor, a step, a ramp) IS the floor: the click point stands.
		//
		// ⚠️ A WALL OR A CEILING STILL DROPS TO THE FLOOR, from just off the surface rather than from 96 units up. Only a
		// placement with no surface at all (`nz_place`, `nz_place_offset`) keeps the 96-unit lift, which an offset point on
		// stairs rising ahead of the player needs to land on the step rather than inside it.
		Vector3 pos;
		if ( normal is Vector3 n && n.z >= 0.7f )
			pos = point;
		else
		{
			var from = normal is Vector3 w ? point + w * 4f : point + Vector3.Up * 96f;
			var down = Scene.Trace
				.Ray( from, from - Vector3.Up * 4100f )
				.IgnoreGameObjectHierarchy( GameObject )
				.Run();

			pos = down.Hit && !down.StartedSolid ? down.HitPosition : point;
		}

		var yaw = Rotation.LookAt( (WorldPosition - pos).WithZ( 0 ) ).Yaw();

		switch ( ActiveTool )
		{
			// ⚠️ THESE TWO ARE HANDLED IN THE CLICK BRANCH AS WELL, and they need to
			// be here too: left-click routes straight to Add*(), but `nz_place` and
			// `nz_place_offset` come through PlaceAt, and without a case they fell to
			// the default and silently did nothing. The mystery box had that gap
			// already — armed from the Q menu it placed on click and ignored the
			// console entirely.
			case NZTools.PackAPunch:
				return AddPackAPunchAt( pos, normal ?? Vector3.Up );

			case NZTools.MysteryBox:
				return AddMysteryBoxAt( pos, normal ?? Vector3.Up );

			// ⚠️ A CASE HERE AS WELL AS THE CLICK PATH. Console placement comes
			// through PlaceAt, and a tool without a case falls to the default and
			// silently does nothing — the gap the mystery box already had.
			case NZTools.Arsenal:
				return AddArsenalAt( pos, normal ?? Vector3.Up );

			case NZTools.PlayerSpawn:
				ActiveConfig.Current.PlayerSpawns.Add( new SpawnPoint( pos, yaw ) );
				Log.Info( $"[nz] player spawn placed at {pos} facing {yaw:0}"
					+ $"  ({ActiveConfig.Current.PlayerSpawns.Count} total)" );
				return true;

			// ⚠️ ON THE CLICK, FACING OUT OF THE SURFACE — the wall buy's placement below, since the first four slots were
			// wall buys. `point`, not the floor-traced `pos`.
			case NZTools.HexSlot:
				return HexSlotManager.PlaceOn( point, normal );

			case NZTools.WallBuy:
			{
				// ⛔ THE CLICK POINT, NOT THE FLOOR — same as the power switch
				// below, and for the same reason: a wallbuy is WALL-MOUNTED, so
				// dropping it to the ground under the wall puts it where nobody
				// pointed. `point`, not `pos`.
				//
				// ⚠️ Faces OUT along the surface normal. Facing along the aim
				// direction would bury the display model inside the wall.
				var wpos = point + (normal ?? Vector3.Up) * 1.5f;
				var wrot = normal is null
					? Rotation.FromYaw( yaw )
					: Rotation.LookAt( normal.Value );

				var mgr = WallBuyManager.Ensure();
				if ( mgr is null ) { Log.Warning( "[nz] no wallbuy manager" ); return true; }

				var buy = mgr.Place( wpos, wrot,
					ToolSettings.WallBuyWeapon, ToolSettings.WallBuyPrice,
					ToolSettings.WallBuyRarity );

				Log.Info( $"[nz] weapon buy placed: {Rarity.NameFor( buy.Rarity )}"
					+ $" {buy.WeaponName} @ {buy.Price}"
					+ $" (ammo {buy.AmmoPrice})"
					+ (normal is null ? "  — no surface, facing yaw" : "  flat to the surface")
					+ $"  ({mgr.All.Count} total)" );
				return true;
			}

			case NZTools.PowerSwitch:
			{
				// ⛔ ON THE CLICK, NOT ON THE FLOOR. This used to take the
				// floor-traced `pos` and lift it half a box — so a switch aimed
				// at a wall dropped to the ground below and stood there, which is
				// never where anyone pointed. A power box is WALL-MOUNTED; the
				// point you clicked is the point it goes.
				//
				// ⚠️ `point`, deliberately, not `pos`. They are the same only
				// when you happen to be aiming at the floor.
				var sw = new PowerSwitch
				{
					Yaw = yaw,
					Position = point,
					Normal = normal ?? Vector3.Zero,
				};

				// A command-driven placement has no surface to read, so it keeps
				// the old floor-and-lift behaviour rather than being left flat.
				if ( normal is null )
					sw.Position = pos + Vector3.Up * (sw.Size.z * 0.5f);

				var wasOn = Power.IsOn;
				ActiveConfig.Current.PowerSwitches.Add( sw );
				PowerManager.Instance?.Rebuild();

				Log.Info( $"[nz] power switch placed at {sw.Position}"
					+ (sw.HasNormal
						? $"  flat to the surface (normal {sw.Normal})"
						: $"  facing {yaw:0} (no surface — dropped to the floor)")
					+ $"  ({ActiveConfig.Current.PowerSwitches.Count} total)" );

				// ⚠️ The FIRST switch turns the map's power off by itself, because
				// Power.IsOn is derived from whether one exists. Silence here
				// reads as "placing a prop broke every door".
				if ( wasOn && !Power.IsOn )
					Log.Info( "[nz] ⚠ first switch — power is now OFF until it is used" );

				return true;
			}

			case NZTools.ZombieSpawn:
				ActiveConfig.Current.ZombieSpawns.Add( new SpawnPoint( pos, yaw )
				{
					// Stamped at placement, not patched in afterwards. You know
					// which side of a door you are standing on WHILE placing;
					// working it out later from a list of coordinates is guesswork.
					Link = SpawnLink,
					ActiveRound = SpawnActiveRound,
					RequiresPower = SpawnRequiresPower,
				} );

				Log.Info( $"[nz] zombie spawn placed at {pos} facing {yaw:0}"
					+ $"  link {SpawnLink}"
					+ (DoorLinks.IsUnlinked( SpawnLink ) ? " (always open)" : "")
					+ (SpawnActiveRound > 1 ? $"  from round {SpawnActiveRound}" : "")
					+ $"  ({ActiveConfig.Current.ZombieSpawns.Count} total)" );
				return true;

			case NZTools.SpecialSpawn:
				ActiveConfig.Current.SpecialSpawns.Add( new SpawnPoint( pos, yaw )
				{
					Special = SpecialEnemies.IsKnown( SpecialEnemy )
						? SpecialEnemy
						: SpecialEnemies.Fallback,
					Link = SpawnLink,
					ActiveRound = SpawnActiveRound,
					RequiresPower = SpawnRequiresPower,
				} );

				Log.Info( $"[nz] special spawn ({SpecialEnemy}) placed at {pos} facing {yaw:0}"
					+ $"  link {SpawnLink}"
					+ (DoorLinks.IsUnlinked( SpawnLink ) ? " (always open)" : "")
					+ $"  ({ActiveConfig.Current.SpecialSpawns.Count} total)" );

				// ⚠️ Placing one is what ENABLES special rounds — IsSpecialRound
				// returns false with no spawners, so an untouched map never has
				// one. Say so on the first, or the setting looks broken.
				if ( ActiveConfig.Current.SpecialSpawns.Count == 1 )
				{
					var sp = ActiveConfig.Current.Specials;
					Log.Info( $"[nz] ⚠ first special spawn — special rounds are now live "
						+ $"(round {sp.FirstRound}, then every {sp.RoundInterval})" );
				}

				return true;

			case NZTools.BossSpawn:
				// ⚠️ THE ROSTER IS FILTERED TO BOSSES, so a mis-set picker cannot drop a hellhound on
				// a boss point. `IsBossName` asks the VARIANT rather than the filename, which is what
				// `SpecialEnemies` insists on for anything that is a rule rather than a loot roll.
				ActiveConfig.Current.BossSpawns.Add( new SpawnPoint( pos, yaw )
				{
					Special = SpecialEnemies.IsBossName( BossEnemy )
						? BossEnemy
						: SpecialEnemies.BossFallback,
					Link = SpawnLink,
					ActiveRound = SpawnActiveRound,
					RequiresPower = SpawnRequiresPower,
				} );

				Log.Info( $"[nz] boss spawn ({BossEnemy}) placed at {pos} facing {yaw:0}"
					+ $"  link {SpawnLink}"
					+ (DoorLinks.IsUnlinked( SpawnLink ) ? " (always open)" : "")
					+ (SpawnActiveRound > 1 ? $"  from round {SpawnActiveRound}" : "")
					+ $"  ({ActiveConfig.Current.BossSpawns.Count} total)" );

				// ⚠️ THE SAME ANNOUNCEMENT THE FIRST SPECIAL SPAWN MAKES, for the reason its own
				// comment gives: with none placed the round type can never fire, so an untouched map
				// never has one and the setting reads as broken until you are told.
				if ( ActiveConfig.Current.BossSpawns.Count == 1 )
					Log.Info( "[nz] → first boss spawn — bosses can now appear. Nothing schedules"
						+ " them yet; `nz_boss_spawn` places one by hand." );

				// ⛔ AND A BOSS NEEDS FLOOR SPACE, WHICH A SPAWN POINT CANNOT CHECK FOR YOU. Brutus is
				// 80 units tall with a 22-unit body radius against a walker's much smaller capsule, so a
				// point that works for a special can still wedge him. Warned here because the editor is
				// where the mistake is made and it stays invisible until one actually spawns.
				Log.Info( "[nz]   ⚠ bosses are large (Brutus: 80u tall, 22u radius) — place these"
					+ " in open floor, not doorways or windows" );

				return true;

			// ⚠️ The two barrier modes want DIFFERENT positions.
			//
			// A BLOCK is authored from clicked corners and spans a gap, so it
			// belongs exactly where it was built — floor-dropping would sink a
			// wall into the ground.
			//
			// A PROP rests on the ground like any other object, so it gets the
			// dropped position. Without this an offset-placed prop hangs in the
			// air, and a nav blocker that does not reach the floor blocks
			// nothing at all.
			case NZTools.Debris:
				return PlaceDebris( DebrisBlockMode ? point : pos, yaw );

			// ⚠️ There is no single-point placement for a wall — it is drawn, not
			// dropped. Say so, rather than falling through to "not implemented
			// yet", which reads as a missing feature instead of a wrong verb.
			case NZTools.InvisibleWall:
			case NZTools.DamageWall:
			case NZTools.RoomZone:
				Log.Warning( $"[nz] a {NZTools.NameFor( ActiveTool ).ToLower()} is DRAWN, not "
					+ $"placed — click {DebrisCorners} corners then once more at the height you "
					+ "want (nz_corner_at / nz_build over the console)" );
				return false;

			default:
				Log.Warning( $"[nz] tool '{ActiveTool}' is not implemented yet" );
				return false;
		}
	}

	/// <summary>
	/// Add a barrier at a point, using the tool's current link and price.
	///
	/// ⚠️ Does NOT re-trace. It used to, which quietly broke offset placement:
	/// nz_place_offset computed a point and PlaceDebris threw it away in favour
	/// of wherever the camera happened to point, so every offset barrier landed
	/// in the same spot.
	/// </summary>
	bool PlaceDebris( Vector3 pos, float yaw )
	{
		// Copy the aimed model when there was one, so a barrier can look like
		// the map's own props instead of a grey box.
		var model = DebrisCopyAimedModel && !string.IsNullOrWhiteSpace( _aimedModel )
			? _aimedModel
			: DebrisModel;

		if ( DoorLinks.IsUnlinked( DebrisLink ) )
			Log.Warning( "[nz] blank/0 is always-open — this barrier will gate nothing" );

		ActiveConfig.Current.Debris.Add( new Debris
		{
			Position = pos,
			Yaw = yaw,
			Model = model,
			Material = DebrisMaterial,
			Visible = DebrisVisible,
			Link = DebrisLink,
			Price = DebrisPrice,
			RequiresPower = DebrisRequiresPower,
			Buyable = DebrisBuyable,
		} );

		Log.Info( $"[nz] debris #{ActiveConfig.Current.Debris.Count - 1} placed at {pos}"
			+ $"  link {DebrisLink}, {DebrisCostWord}, {model}"
			+ (DebrisRequiresPower ? "  needs power" : "") );

		DebrisManager.Ensure( Scene )?.Rebuild();
		return true;
	}

	/// <summary>Remove the marker you are aiming at — not the nearest one to
	/// you, which would delete things behind your back.</summary>
	public bool RemoveAimed()
	{
		var hit = AimTrace();
		if ( hit is null ) return false;

		var at = hit.Value.HitPosition;

		// Debris and switches live in their own lists with their own types, so
		// they cannot share the SpawnPoint path below. ⚠️ Anything with its own
		// list needs a case HERE as well as in PlaceAt — a tool that places but
		// cannot remove looks like RMB is broken rather than unimplemented.
		if ( ActiveTool == NZTools.Debris ) return RemoveDebrisNear( at );
		if ( ActiveTool == NZTools.PowerSwitch ) return RemoveSwitchNear( at );
		if ( ActiveTool == NZTools.InvisibleWall ) return RemoveWallNear( at, hit.Value.GameObject );
		if ( ActiveTool == NZTools.DamageWall ) return RemoveDamageWallNear( at );
		if ( ActiveTool == NZTools.FogArea ) return RemoveFogAreaNear( at );
		if ( ActiveTool == NZTools.RoomZone ) return RemoveRoomZoneNear( at );
		if ( ActiveTool == NZTools.Barricade ) return RemoveBarricadeNear( at );
		if ( ActiveTool == NZTools.NavLink ) return RemoveNavLinkNear( at );
		if ( ActiveTool == NZTools.MysteryBox ) return RemoveMysteryBoxNear( at );
		if ( ActiveTool == NZTools.PackAPunch ) return RemovePackAPunchNear( at );
		if ( ActiveTool == NZTools.Wunderfizz ) return RemoveWunderfizzNear( at );
		if ( ActiveTool == NZTools.PerkMachine ) return RemovePerkMachineNear( at );
		if ( ActiveTool == NZTools.Teleporter ) return RemoveTeleporterNear( at );
		if ( ActiveTool == NZTools.Springboard ) return RemoveSpringboardNear( at );
		if ( ActiveTool == NZTools.MapLight ) return RemoveMapLightNear( at );
		if ( ActiveTool == NZTools.SoundSpot ) return RemoveSoundSpotNear( at );
		if ( ActiveTool == NZTools.SoulBox ) return RemoveSoulBoxNear( at );
		if ( ActiveTool == NZTools.AmmoBox ) return RemoveAmmoBoxNear( at );
		if ( ActiveTool == NZTools.BuyableEnding ) return RemoveEndingNear( at );
		if ( ActiveTool == NZTools.Misery ) return RemoveMiseryNear( at );
		if ( ActiveTool == NZTools.Clue ) return RemoveClueNear( at );
		if ( ActiveTool == NZTools.Pressable ) return RemovePressableNear( at );
		if ( ActiveTool == NZTools.Shootable ) return RemoveShootableNear( at );
		if ( ActiveTool == NZTools.TradeTable ) return RemoveTradeTableNear( at );
		if ( ActiveTool == NZTools.BuildTable ) return RemoveBuildTableNear( at );
		if ( ActiveTool == NZTools.BuildPart ) return RemoveBuildPartNear( at );
		if ( ActiveTool == NZTools.Arsenal ) return RemoveArsenalNear( at );
		if ( ActiveTool == NZTools.WallBuy ) return RemoveWallBuyNear( at );
		if ( ActiveTool == NZTools.HexSlot ) return HexSlotManager.RemoveNear( at, RemoveRadius );

		var list = ListFor( ActiveTool );
		if ( list is null ) return false;

		var found = list
			.Select( ( s, i ) => (s, i, d: s.Position.Distance( at )) )
			.Where( x => x.d <= RemoveRadius )
			.OrderBy( x => x.d )
			.FirstOrDefault();

		if ( found.s is null )
		{
			Log.Info( $"[nz] no {NZTools.NameFor( ActiveTool ).ToLower()} under the crosshair" );
			return false;
		}

		list.RemoveAt( found.i );
		Log.Info( $"[nz] {NZTools.NameFor( ActiveTool ).ToLower()} removed  ({list.Count} left)" );
		return true;
	}

	/// <summary>
	/// If the crosshair is on a standing barrier, push the tool's current
	/// settings onto it and report true. False means "not aiming at one" —
	/// which is the signal to build a new one instead.
	///
	/// Applies link, price and material only. NOT size or position: those were
	/// authored by clicking a footprint, and silently resizing a wall because
	/// you clicked it would be the opposite of an edit.
	/// </summary>
	/// <summary>Log what the click landed on when it built instead of edited.
	/// Off by default — it fires on every corner click.</summary>
	[Property] public bool DebrisDebug { get; set; }

	void ReportAimedForEdit()
	{
		var hit = AimTrace();
		if ( hit is null ) { Log.Info( "[nz] edit-check: aiming at nothing" ); return; }

		var go = hit.Value.GameObject;
		var m = DebrisManager.Instance;

		Log.Info( $"[nz] edit-check: hit '{go?.Name ?? "null"}'"
			+ $"  parent '{go?.Parent?.Name ?? "none"}'"
			+ $"  manager {(m.IsValid() ? "ok" : "MISSING")}"
			+ $"  index {(m.IsValid() ? m.IndexOfObject( go ) : -1)}"
			+ "  -> building a new one" );
	}

	public bool ApplyToAimedDebris()
	{
		var m = DebrisManager.Instance;
		if ( !m.IsValid() ) return false;

		var hit = AimTrace();
		if ( hit is null ) return false;

		var index = m.IndexOfObject( hit.Value.GameObject );
		if ( index < 0 ) return false;

		var list = ActiveConfig.Current.Debris;
		if ( index >= list.Count ) return false;

		var d = list[index];
		var before = $"flag {DoorLinks.Display( d.Link )}, {d.Price}pts"
			+ (d.RequiresPower ? ", power" : "") + (d.Buyable ? "" : ", no buy");

		// ⚠️ EVERY TOOL SETTING, not a subset. RequiresPower was added to the
		// tool later and never added here, so clicking a wall applied the flag,
		// price and material and silently left the power gate as it was — the
		// one setting whose effect you cannot see on the marker.
		d.Link = DebrisLink;
		d.Price = DebrisPrice;
		d.Material = DebrisMaterial;
		d.RequiresPower = DebrisRequiresPower;
		d.Buyable = DebrisBuyable;

		Log.Info( $"[nz] debris #{index}: {before} -> flag {DoorLinks.Display( d.Link )}, "
			+ $"{d.Price}pts{(d.RequiresPower ? ", power" : "")}{(d.Buyable ? "" : ", no buy")}"
			+ "  (unsaved — nz_save to keep it)" );

		// Rebuild so a material change is visible immediately — an edit you
		// cannot see is indistinguishable from one that did not happen.
		m.Rebuild();
		return true;
	}

	// ── block building ───────────────────────────────────────────────────────

	/// <summary>
	/// Add a footprint corner where you are aiming — or, once the footprint is
	/// closed, set the height from where you are aiming.
	/// </summary>
	public bool AddCorner()
	{
		var hit = AimTrace();

		if ( hit is null )
		{
			// ⚠️ Says which click was lost. Aiming at nothing while placing the
			// FOURTH corner and aiming at nothing while setting the height fail
			// identically and mean different things — the second usually means
			// the player aimed at open sky instead of at the wall.
			Log.Info( _awaitingHeight
				? "[nz] height: aiming at nothing — aim at the wall (or the ceiling) "
					+ "at the height you want the barrier to reach"
				: "[nz] corner: aiming at nothing" );
			return false;
		}

		return AddCornerAt( hit.Value.HitPosition );
	}

	/// <summary>Add a corner at an explicit point. Separate from AddCorner so a
	/// command can drive it — nobody can click four points over MCP.</summary>
	public bool AddCornerAt( Vector3 at )
	{
		// The footprint is already closed, so this click is the HEIGHT.
		if ( _awaitingHeight ) return SetHeightAt( at );

		_corners.Add( at );
		Log.Info( $"[nz] corner {_corners.Count}/{DebrisCorners} at {at}" );

		if ( _corners.Count < DebrisCorners ) return true;

		_awaitingHeight = true;
		Log.Info( "[nz] footprint closed — now click at the HEIGHT you want, "
			+ "somewhere level with the top of the barrier (the top of the doorway, "
			+ "say). R starts over." );
		return true;
	}

	/// <summary>
	/// Close the block, taking its height from a clicked point.
	///
	/// ⛔ MEASURED FROM THE LOWEST CORNER, not from the click's own footing. The
	/// block's base is already the lowest corner (see BuildBlock), so measuring
	/// from anything else would give a barrier whose top is not where you clicked
	/// — which is the one thing this whole flow exists to guarantee.
	/// </summary>
	public bool SetHeightAt( Vector3 at )
	{
		if ( _corners.Count < 2 )
		{
			Log.Warning( "[nz] no footprint to raise — click the corners first" );
			_awaitingHeight = false;
			return false;
		}

		float groundZ = float.MaxValue;
		foreach ( var c in _corners )
			groundZ = MathF.Min( groundZ, c.z );

		float height = at.z - groundZ;

		// ⚠️ NOT SILENTLY SUBSTITUTED. Falling back to DebrisHeight here would
		// build a barrier of a height nobody asked for, and it would look like the
		// click had worked. Keep the footprint pending so the next click can fix
		// it rather than making them re-place four corners.
		if ( height <= 1f )
		{
			Log.Warning( $"[nz] that point is only {height:0} above the footprint's "
				+ "base — aim higher up the wall. Corners kept; click again." );
			return false;
		}

		_awaitingHeight = false;
		return BuildBlock( height );
	}

	public void ResetCorners()
	{
		if ( _corners.Count == 0 && !_awaitingHeight ) return;

		Log.Info( $"[nz] cleared {_corners.Count} pending corners" );
		_corners.Clear();

		// ⚠️ Cleared with the corners. Left set, the first click of the NEXT
		// barrier would be read as a height for a footprint that no longer exists.
		_awaitingHeight = false;
	}

	/// <summary>
	/// A measured block: where it sits, which way it faces, how big its box is,
	/// and the polygon it was drawn as.
	///
	/// ⚠️ EXISTS SO THE INVISIBLE-WALL TOOL CANNOT DRIFT FROM THE DEBRIS TOOL.
	/// "It works exactly like the debris one" was the requirement, and the only
	/// way to keep that true a month from now is for both to run the SAME
	/// measurement rather than two copies that start identical.
	/// </summary>
	public readonly record struct BlockShape(
		Vector3 Position, float Yaw, Vector3 Size,
		System.Collections.Generic.List<Vector2> Footprint );

	/// <summary>
	/// Turn the pending corners into whatever the armed tool builds.
	///
	/// Debris by default; an invisible wall when that tool is armed. The geometry
	/// is identical either way — see MeasureBlock.
	/// </summary>
	public bool BuildBlock( float height )
	{
		if ( !MeasureBlock( height, out var shape ) ) return false;

		bool built = ActiveTool switch
		{
			NZTools.InvisibleWall => AddInvisibleWall( shape ),
			NZTools.DamageWall => AddDamageWall( shape ),
			NZTools.FogArea => AddFogArea( shape ),
			NZTools.RoomZone => AddRoomZone( shape ),
			_ => AddDebris( shape ),
		};

		if ( !built ) return false;

		_corners.Clear();

		// ⚠️ Also cleared here, not only in ResetCorners — nz_build can close a
		// block while the tool is waiting for a height click, and leaving the flag
		// set would eat the first corner of the next one.
		_awaitingHeight = false;
		return true;
	}

	/// <summary>
	/// Measure the pending corners into a block of the given height.
	///
	/// ⚠️ ORIENTED, not axis-aligned. An axis-aligned box is simpler but wrong for
	/// the common case: a doorway at 30° would get a bounding box far wider than
	/// the gap, sealing the wall on either side of it too.
	///
	/// ⚠️ AND THE ORIENTATION IS THE TIGHTEST ONE, NOT THE FIRST EDGE. Yaw used to
	/// come from corner 0 -> corner 1, which made the result depend on the ORDER
	/// the corners were clicked in — see the block in the body.
	/// </summary>
	bool MeasureBlock( float height, out BlockShape shape )
	{
		shape = default;

		if ( _corners.Count < 2 )
		{
			Log.Warning( $"[nz] need at least 2 corners, have {_corners.Count}" );
			return false;
		}

		if ( height <= 1f ) { Log.Warning( "[nz] height must be > 1" ); return false; }

		// ⛔ ORIENTATION IS CHOSEN, NOT ASSUMED FROM THE FIRST EDGE.
		//
		// This used to take yaw from corner 0 -> corner 1 and measure everything
		// else in that frame. That makes CLICK ORDER decide the shape: start with
		// two corners that happen to run diagonally across a doorway and the
		// remaining corners fall outside that axis, so the bounding box balloons
		// out to span the diagonal. Reported exactly as "it always makes a
		// rectangle with the 2 furthest from each other" — which is precisely what
		// a box measured along the wrong axis looks like.
		//
		// ⚠️ EVERY EDGE IS A CANDIDATE, SMALLEST FOOTPRINT WINS. The minimum-area
		// enclosing rectangle of a point set is always aligned with one of the
		// edges of its convex hull (rotating calipers), so trying each edge
		// between clicked points and keeping the tightest is exact, not a
		// heuristic. With four corners that is six candidates — free.
		//
		// Result: the same four clicks give the same block in any order, and a
		// doorway at any angle gets a block the width of the doorway.
		float yaw = 0f;
		var localMin = new Vector3( float.MaxValue );
		var localMax = new Vector3( float.MinValue );

		float bestArea = float.MaxValue;

		for ( int i = 0; i < _corners.Count; i++ )
		for ( int j = i + 1; j < _corners.Count; j++ )
		{
			var edge = (_corners[j] - _corners[i]).WithZ( 0 );

			// Two clicks in the same spot describe no direction — skip rather
			// than feed a zero vector to LookAt and get a NaN rotation.
			if ( edge.Length <= 1f ) continue;

			float tryYaw = Rotation.LookAt( edge ).Yaw();
			var tryRot = Rotation.FromYaw( tryYaw );

			var min = new Vector3( float.MaxValue );
			var max = new Vector3( float.MinValue );

			foreach ( var c in _corners )
			{
				var local = tryRot.Inverse * c;
				min = Vector3.Min( min, local );
				max = Vector3.Max( max, local );
			}

			// Footprint only — height is applied later and is the same whichever
			// way the block is turned, so including it would not change the
			// ranking but would hide a degenerate footprint behind a tall box.
			float area = (max.x - min.x) * (max.y - min.y);
			if ( area >= bestArea ) continue;

			bestArea = area;
			yaw = tryYaw;
			localMin = min;
			localMax = max;
		}

		// Every candidate edge was degenerate — all the clicks landed on the same
		// spot. Fall back to the axis-aligned measurement rather than building
		// from uninitialised extents.
		if ( bestArea == float.MaxValue )
		{
			var rot0 = Rotation.FromYaw( 0f );
			localMin = new Vector3( float.MaxValue );
			localMax = new Vector3( float.MinValue );

			foreach ( var c in _corners )
			{
				var local = rot0.Inverse * c;
				localMin = Vector3.Min( localMin, local );
				localMax = Vector3.Max( localMax, local );
			}
		}

		var rot = Rotation.FromYaw( yaw );

		float groundZ = float.MaxValue;
		foreach ( var c in _corners )
			groundZ = MathF.Min( groundZ, c.z );

		// A two-click footprint has no depth — give it some, or the block is an
		// invisible plane you can see straight through edge-on.
		var size = localMax - localMin;
		size.x = MathF.Max( size.x, 8f );
		size.y = MathF.Max( size.y, 8f );

		// ⚠️ SUNK BELOW THE FLOOR ON PURPOSE. The base is the lowest corner, but
		// a corner that landed on a crate or a kerb puts that base ABOVE the
		// walkable surface — and a nav blocker floating even a few units clear
		// of the floor does not block anything, because the navmesh generates
		// underneath it. Measured: a block resting 2u high left the path
		// completely unchanged. Sinking by more than the agent step (24) makes
		// the block bite into the floor whatever the corners hit.
		size.z = height + Sink;

		// Centre on the footprint horizontally, and sit it on the lowest corner
		// with the sink hanging below.
		var localCentre = (localMin + localMax) * 0.5f;
		var centre = rot * localCentre;
		centre.z = groundZ + height * 0.5f - Sink * 0.5f;

		// ⛔ THE ACTUAL CORNERS, NOT JUST THEIR BOUNDING BOX. `size` above is kept
		// because nav rebuilding and the remove radius both want a box, but the
		// barrier is BUILT from these points — so four corners describing an L or
		// a wedge produce an L or a wedge instead of the rectangle around them.
		//
		// ⚠️ Stored in the barrier's own frame: rotated into the block's yaw and
		// measured from its centre, so the polygon travels with Position/Yaw and
		// needs no world coordinates saved alongside it.
		//
		// ⚠️ Only worth keeping for 3+ corners. Two clicks describe a line, which
		// has no interior to extrude — those stay plain boxes, which is exactly
		// what the two-corner flow was always for.
		// ⚠️ IN CLICK ORDER, deliberately — that IS the drawing. Sorting the
		// corners (by angle around the centre, say) would look tidier and would
		// quietly convexify every shape, turning an L back into the rectangle
		// this change exists to stop producing.
		//
		// The cost of honouring click order is that clicking a doorway's corners
		// diagonally draws a bowtie, which has no interior. Caught below rather
		// than built into a mess.
		var footprint = new System.Collections.Generic.List<Vector2>();
		if ( _corners.Count >= 3 )
		{
			foreach ( var c in _corners )
			{
				var local = (rot.Inverse * c) - localCentre;
				footprint.Add( new Vector2( local.x, local.y ) );
			}

			if ( !DebrisMesh.IsSimple( footprint ) )
			{
				Log.Warning( "[nz] those corners cross over themselves — clicked "
					+ "diagonally rather than around the shape. Built as a plain box; "
					+ "press R and click the corners in order around the edge to get "
					+ "the drawn shape." );
				footprint.Clear();
			}
			// ⚠️ SAID UP FRONT, because the alternative is finding out as a pathing
			// bug on a map you have already built. The barrier LOOKS right — it is
			// the nav volume that cannot follow it.
			//
			// ⚠️ NOT WARNED FOR AN INVISIBLE WALL. That tool does not touch the
			// navmesh at all, so a dent costs it nothing — and a warning about a
			// problem the thing you are building cannot have is how people learn
			// to ignore warnings.
			else if ( ActiveTool != NZTools.InvisibleWall && ActiveTool != NZTools.DamageWall
				&& ActiveTool != NZTools.RoomZone && !DebrisMesh.IsConvex( footprint ) )
			{
				Log.Warning( "[nz] this shape has a dent in it. The barrier is built "
					+ "to the outline, but the nav block is a box, so zombies will "
					+ "not path through the notch either. Two barriers side by side "
					+ "if the gap needs to stay walkable." );
			}
		}

		shape = new BlockShape( centre, yaw, size, footprint );
		return true;
	}

	/// <summary>Record a debris barrier from a measured block.</summary>
	// ── mystery box ──────────────────────────────────────────────────────────

	/// <summary>Drop a box where you are looking, facing you.</summary>
	public bool AddMysteryBox()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddMysteryBoxAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddMysteryBoxAt( Vector3 at ) => AddMysteryBoxAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddMysteryBoxAt( Vector3 at, Vector3 normal )
	{
		// ⚠️ Faces the placer, like the barricade tool and the original's own
		// tool do — you author it from where the player will approach it.
		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.Boxes.Add( new MysteryBoxSpot
		{
			Position = at,
			Yaw = yaw,
			Cost = Math.Max( 0, BoxPrice ),
			CanStart = BoxCanStart,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
		} );
		MysteryBoxManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] mystery box placed at {at} facing {yaw:0}° "
			+ $"({ActiveConfig.Current.Boxes.Count} total)" );
		return true;
	}

	/// <summary>Remove the box nearest a point. Right-click, via RemoveAimed.</summary>
	bool RemoveMysteryBoxNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Boxes;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 120f;

		for ( int i = 0; i < list.Count; i++ )
		{
			float d = list[i].Position.Distance( at );
			if ( d >= bestDist ) continue;
			bestDist = d;
			best = i;
		}

		if ( best < 0 ) return false;

		list.RemoveAt( best );
		MysteryBoxManager.Ensure( Scene )?.Rebuild();
		Log.Info( $"[nz] mystery box removed ({list.Count} left)" );
		return true;
	}

	// ── pack-a-punch ─────────────────────────────────────────────────────────

	/// <summary>
	/// Drop a Pack-a-Punch where you are looking, facing you.
	///
	/// ⚠️ DELIBERATELY THE SAME FLOW AS THE MYSTERY BOX — one click, faces the
	/// placer, floor normal stored. Both are floor-standing props placed the same
	/// way, and a second placement convention is a second set of slope bugs.
	/// </summary>
	public bool AddPackAPunch()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddPackAPunchAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddPackAPunchAt( Vector3 at ) => AddPackAPunchAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddPackAPunchAt( Vector3 at, Vector3 normal )
	{
		// ⚠️ Refuses a surface too steep to stand on rather than mounting the
		// machine sideways on it. `nz_pap` learned this the hard way — a machine
		// ended up on a wall with up=(-0.82,0.57,0), and nothing about the result
		// says "you aimed at a wall".
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz] that surface is too steep for a Pack-a-Punch — aim at the floor" );
			return false;
		}

		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.PackAPunches.Add( new PackAPunchSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
		} );
		PackAPunchManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] Pack-a-Punch placed at {at} facing {yaw:0}° "
			+ $"({ActiveConfig.Current.PackAPunches.Count} total)" );
		return true;
	}

	// ── Wunderfizz options, stamped at placement ─────────────────────────────
	//
	// ⚠️ Stamped onto the spot rather than read live, the same as every other
	// placeable here. You know what a machine is FOR while you are standing where
	// it goes; working it out later from a list of coordinates is guesswork.

	/// <summary>Cost of the first roll.</summary>
	[Property] public int WunderfizzPrice { get; set; } = 2500;

	/// <summary>Added to the price after each roll. 0 = flat.</summary>
	[Property] public int WunderfizzIncrement { get; set; } = 500;

	/// <summary>Earliest round it can be used.</summary>
	[Property] public int WunderfizzStartRound { get; set; }

	/// <summary>Needs the power on.</summary>
	[Property] public bool WunderfizzRequiresPower { get; set; } = true;

	/// <summary>Cost of an extra perk slot here. 0 = not offered.</summary>
	[Property] public int WunderfizzSlotPrice { get; set; } = 10000;

	/// <summary>Added to the slot price for every extra slot the player has already
	/// bought. 0 = flat.
	///
	/// ⚠️ Same shape as `WunderfizzIncrement`, deliberately — the roll price and the
	/// slot price now escalate by the same rule, so a mapper who has set one already
	/// knows what this one does.</summary>
	[Property] public int WunderfizzSlotIncrement { get; set; }

	/// <summary>Wunderfizz from the crosshair. Mirrors AddPackAPunch.</summary>
	public bool AddWunderfizz()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddWunderfizzAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddWunderfizzAt( Vector3 at ) => AddWunderfizzAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddWunderfizzAt( Vector3 at, Vector3 normal )
	{
		// ⚠️ Same steep-surface refusal as Pack-a-Punch, and for the reason that one
		// records: a machine mounted sideways on a wall looks like a placement that
		// worked, and nothing about the result says you aimed at a wall.
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz] that surface is too steep for a Wunderfizz — aim at the floor" );
			return false;
		}

		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.Wunderfizzes.Add( new WunderfizzSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			BasePrice = WunderfizzPrice,
			PriceIncrement = WunderfizzIncrement,
			StartRound = WunderfizzStartRound,
			RequiresPower = WunderfizzRequiresPower,
			PerkSlotPrice = WunderfizzSlotPrice,
			PerkSlotIncrement = WunderfizzSlotIncrement,
			Link = SpawnLink,
		} );
		WunderfizzManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] Wunderfizz placed at {at} facing {yaw:0}° "
			+ $"{WunderfizzPrice} points "
			+ $"({ActiveConfig.Current.Wunderfizzes.Count} total)" );
		return true;
	}


	// ── Ammo box and trading table ───────────────────────────────────────────
	//
	// ⚠️ NO OPTIONS STAMPED AT PLACEMENT, unlike the Wunderfizz above. The ammo box's prices are
	// GLOBAL (`AmmoBoxSettings`, edited in Q > Settings > Ammo box) because they key off the
	// weapon's Pack-a-Punch tier rather than off which box you walked to, and the trading table is
	// free. Both spots carry position, facing, floor normal and a door flag - nothing else to ask.

	// ── Perk machine options, stamped at placement ───────────────────────────

	/// <summary>
	/// Which perk the next machine sells, as a PerkRegistry id.
	///
	/// ⚠️ AN ID, NOT AN INDEX. The settings row shows names and the config stores ids, so an
	/// index would silently repoint every placed machine the day a perk is added to the middle
	/// of PerkRegistry.All.
	/// </summary>
	[Property] public string PerkMachinePerk { get; set; } = "jugg";

	/// <summary>Cost of the next machine, flat. -1 = auto, the Wunderfizz's price
	/// (PerkMachine.PriceFor).</summary>
	[Property] public int PerkMachinePrice { get; set; } = -1;

	/// <summary>Earliest round the next machine can be used.</summary>
	[Property] public int PerkMachineStartRound { get; set; }

	/// <summary>Needs the power on. ON BY DEFAULT, matching the Wunderfizz and the original.</summary>
	[Property] public bool PerkMachineRequiresPower { get; set; } = true;

	/// <summary>The perk the tool will place, resolved. Null if the id is unknown.</summary>
	public PerkRegistry.Perk PerkMachinePerkInfo => PerkRegistry.Find( PerkMachinePerk );

	/// <summary>Perk machine from the crosshair. Mirrors AddWunderfizz.</summary>
	public bool AddPerkMachine()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddPerkMachineAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddPerkMachineAt( Vector3 at ) => AddPerkMachineAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddPerkMachineAt( Vector3 at, Vector3 normal )
	{
		// ⛔ REFUSED BEFORE ANYTHING IS WRITTEN. A spot whose perk has no machine model saves
		// happily and then builds nothing, which reads as the placement having failed silently.
		var perk = PerkMachinePerkInfo;
		if ( perk is null )
		{
			Log.Warning( $"[nz] '{PerkMachinePerk}' is not a perk — nothing placed" );
			return false;
		}

		if ( string.IsNullOrEmpty( PerkMachineManager.ModelFor( perk.Id ) ) )
		{
			Log.Warning( $"[nz] {perk.Name} has no machine model — nothing placed" );
			return false;
		}

		// ⚠️ Same steep-surface refusal as the Wunderfizz: a machine mounted sideways on a wall
		// looks like a placement that worked, and nothing about the result says you aimed at a wall.
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz] that surface is too steep for a perk machine — aim at the floor" );
			return false;
		}

		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.PerkMachines.Add( new PerkMachineSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			PerkId = perk.Id,
			Price = PerkMachinePrice,
			StartRound = PerkMachineStartRound,
			RequiresPower = PerkMachineRequiresPower,
			Link = SpawnLink,
		} );
		PerkMachineManager.Ensure( Scene )?.Rebuild();

		var price = PerkMachinePrice >= 0
			? $"{PerkMachinePrice} points"
			: $"the Wunderfizz's price ({Wunderfizz.PerkPriceRule()})";
		Log.Info( $"[nz] {perk.Name} machine placed at {at} facing {yaw:0}° "
			+ $"{price}{(PerkMachineRequiresPower ? ", needs power" : "")} "
			+ $"({ActiveConfig.Current.PerkMachines.Count} total)" );
		return true;
	}

	/// <summary>Remove the perk machine nearest a point. Right-click, via RemoveAimed.</summary>
	bool RemovePerkMachineNear( Vector3 at )
	{
		var list = ActiveConfig.Current.PerkMachines;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no perk machine near there" ); return false; }

		var name = PerkRegistry.Find( list[best].PerkId )?.Name ?? list[best].PerkId;
		list.RemoveAt( best );
		PerkMachineManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] removed the {name} machine ({list.Count} left)" );
		return true;
	}

	// ── Teleporter options, stamped at placement ─────────────────────────────

	/// <summary>Cost per trip for the next teleporter. 0 = free.</summary>
	[Property] public int TeleporterPrice { get; set; }

	/// <summary>Seconds the next teleporter takes to recharge. 0 = no cooldown.
	/// ⚠️ 20 is upstream's own default — see TeleporterSpot.Cooldown.</summary>
	[Property] public float TeleporterCooldown { get; set; } = 20f;

	/// <summary>Seconds between the press and departure. ⚠️ 2.5 is upstream's own.</summary>
	[Property] public float TeleporterWarmup { get; set; } = 2.5f;

	/// <summary>Seconds spent in transit, frozen and blind. ⚠️ 4 is upstream's own.</summary>
	[Property] public float TeleporterTransit { get; set; } = 4f;

	/// <summary>How wide the next pad is — and therefore who counts as standing on it.
	/// ⚠️ 176 is measured from the pad model — see TeleporterSpot.PadSize.</summary>
	[Property] public float TeleporterPadSize { get; set; } = 176f;

	/// <summary>How thick the next placeholder box is. Above ~18 it becomes a kerb you have to
	/// jump; 12 matches the real pad.</summary>
	[Property] public float TeleporterPadHeight { get; set; } = 12f;

	/// <summary>Needs the power on. ⚠️ OFF by default — see TeleporterSpot.RequiresPower.</summary>
	[Property] public bool TeleporterRequiresPower { get; set; }

	/// <summary>The next pad's model. Empty = the placeholder box.</summary>
	[Property] public string TeleporterModel { get; set; } = "models/moo/_codz_ports_props/t5/zm/zombie_teleporter_pad/moo_codz_zm_teleporter_pad.vmdl";

	/// <summary>Surface for the placeholder pad box. Only used when Model is empty.</summary>
	[Property] public string TeleporterMaterial { get; set; } = "materials/metal/metalwall048a.vmat";

	/// <summary>
	/// Teleporter point from the crosshair — the PAD first, then the destination.
	///
	/// ⚠️ Uses the shared `_corners` buffer, the same as the nav link and the block tools, so the
	/// pending markers already draw and R already clears it. A private two-point buffer would be a
	/// second thing to reset and a second thing to forget to draw.
	/// </summary>
	public bool AddTeleporterPoint()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddTeleporterPointAt( hit.Value.HitPosition );
	}

	/// <summary>Place a point explicitly, so a command can drive it.</summary>
	public bool AddTeleporterPointAt( Vector3 at )
	{
		_corners.Add( at );

		if ( _corners.Count < 2 )
		{
			Log.Info( $"[nz] teleporter pad at {at} — now click where it SENDS you" );
			return true;
		}

		var a = _corners[0];
		var b = _corners[1];
		_corners.Clear();
		_awaitingHeight = false;

		// ⛔ REFUSED, NOT SILENTLY BUILT. A teleporter whose ends are the same spot looks placed
		// and does nothing, which is the worst of both — it reads as the destination click having
		// been missed rather than as a rejected placement.
		if ( a.Distance( b ) < 16f )
		{
			Log.Warning( "[nz] both ends are the same spot — a teleporter needs to go "
				+ "somewhere. Points cleared; start again." );
			return false;
		}

		// ⚠️ FACING ALONG A -> B, so you step out looking the way you travelled. Arriving facing
		// backwards is disorienting in a way that reads as the teleporter being broken.
		var yaw = (b - a).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.Teleporters.Add( new TeleporterSpot
		{
			A = a,
			B = b,
			Yaw = yaw,
			PadSize = TeleporterPadSize,
			PadHeight = TeleporterPadHeight,
			Model = TeleporterModel,
			Material = TeleporterMaterial,
			Price = TeleporterPrice,
			Cooldown = TeleporterCooldown,
			WarmupTime = TeleporterWarmup,
			TransitTime = TeleporterTransit,
			RequiresPower = TeleporterRequiresPower,
			Link = SpawnLink,
		} );
		TeleporterManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] teleporter placed: {a} -> {b}, {a.Distance( b ):0}u"
			+ (TeleporterPrice > 0 ? $", {TeleporterPrice} points" : ", free")
			+ $", {TeleporterWarmup:0.#}s warmup + {TeleporterTransit:0.#}s transit"
			+ (TeleporterCooldown > 0f ? $", {TeleporterCooldown:0}s cooldown" : "")
			+ (TeleporterRequiresPower ? ", needs power" : "")
			+ $" ({ActiveConfig.Current.Teleporters.Count} total)" );
		return true;
	}

	/// <summary>
	/// Remove the teleporter nearest a point. Right-click, via RemoveAimed.
	///
	/// ⚠️ MATCHES ON EITHER END. You will right-click whichever end you happen to be standing at,
	/// and only ever being able to delete from the pad end is a rule nothing on screen states.
	/// </summary>
	bool RemoveTeleporterNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Teleporters;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = MathF.Min( at.Distance( list[i].A ), at.Distance( list[i].B ) );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no teleporter near there" ); return false; }

		list.RemoveAt( best );
		TeleporterManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] removed teleporter #{best} ({list.Count} left)" );
		return true;
	}

	// ── Soul box options, stamped at placement ───────────────────────────────

	/// <summary>Kills needed to fill the next box. ⚠️ 20 is what upstream's map scripts use.</summary>
	[Property] public int SoulBoxTarget { get; set; } = 20;

	/// <summary>How far a kill still counts. ⚠️ 500 is upstream's own default.</summary>
	[Property] public float SoulBoxRange { get; set; } = 500f;

	/// <summary>Needs the power on to take souls.</summary>
	[Property] public bool SoulBoxRequiresPower { get; set; }

	/// <summary>Drop a random powerup when the box fills. ⚠️ Per BOX — five boxes pay five.</summary>
	[Property] public bool SoulBoxPowerup { get; set; } = true;

	/// <summary>The next box's model. Empty = the placeholder box.</summary>
	[Property] public string SoulBoxModel { get; set; } = "models/zmb/bo2/tomb/zm_tm_soul_box.vmdl";

	/// <summary>How wide and tall the placeholder box is. Only used when Model is empty.</summary>
	[Property] public float SoulBoxSize { get; set; } = 48f;
	[Property] public float SoulBoxHeight { get; set; } = 48f;

	/// <summary>Surface for the placeholder box.</summary>
	[Property] public string SoulBoxMaterial { get; set; } = "materials/metal/metalwall048a.vmat";

	/// <summary>Soul box from the crosshair. Mirrors AddWunderfizz.</summary>
	public bool AddSoulBox()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddSoulBoxAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddSoulBoxAt( Vector3 at ) => AddSoulBoxAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddSoulBoxAt( Vector3 at, Vector3 normal )
	{
		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.SoulBoxes.Add( new SoulBoxSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			Target = SoulBoxTarget,
			Range = SoulBoxRange,
			RequiresPower = SoulBoxRequiresPower,
			Powerup = SoulBoxPowerup,
			Model = SoulBoxModel,
			Size = SoulBoxSize,
			Height = SoulBoxHeight,
			Material = SoulBoxMaterial,
			Link = SpawnLink,
		} );
		SoulBoxManager.Ensure( Scene )?.Rebuild();

		// ⚠️ SAYS HOW MANY BOXES NOW SHARE THIS FLAG, which is the number that decides when it
		// opens. Placing the fourth of five and being told only "placed" leaves the one fact the
		// author is tracking off the screen.
		var sameFlag = ActiveConfig.Current.SoulBoxes
			.Count( b => DoorLinks.Same( b.Link, SpawnLink ) );

		Log.Info( $"[nz] soul box placed at {at}  {SoulBoxTarget} kills within {SoulBoxRange:0}u"
			+ (SoulBoxRequiresPower ? ", needs power" : "")
			+ (SoulBoxPowerup ? ", drops a powerup" : "")
			+ $"  ·  flag {DoorLinks.Display( SpawnLink )}"
			+ (DoorLinks.IsUnlinked( SpawnLink ) ? " (gates nothing)" : $" — {sameFlag} box(es) on it")
			+ $"  ({ActiveConfig.Current.SoulBoxes.Count} total)" );
		return true;
	}

	/// <summary>Remove the soul box nearest a point. Right-click, via RemoveAimed.</summary>
	bool RemoveSoulBoxNear( Vector3 at )
	{
		var list = ActiveConfig.Current.SoulBoxes;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no soul box near there" ); return false; }

		var flag = list[best].Link;
		list.RemoveAt( best );
		SoulBoxManager.Ensure( Scene )?.Rebuild();

		// ⚠️ THE REMAINING COUNT ON THAT FLAG IS THE POINT. Removing one box makes its flag open a
		// kill-count sooner, and removing the LAST one makes it never open at all.
		var left = list.Count( b => DoorLinks.Same( b.Link, flag ) );
		Log.Info( $"[nz] removed soul box #{best} (flag {DoorLinks.Display( flag )}"
			+ $" now has {left} box(es))  ·  {list.Count} total" );
		return true;
	}

	/// <summary>Ammo box from the crosshair. Mirrors AddWunderfizz.</summary>
	/// <summary>
	/// Place a light where you are aiming.
	///
	/// ⚠️ LIFTED OFF THE SURFACE. A light sitting exactly on a wall lights almost nothing — half
	/// its sphere is inside the geometry — and reads as a light that does not work. 16 units along
	/// the surface normal puts it in the room.
	///
	/// ⚠️ AND NO STEEP-SURFACE REFUSAL, unlike every machine placeable. A light on a wall or a
	/// ceiling is the normal case, not a mistake.
	/// </summary>
	public bool AddMapLight()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at a surface" );
			return false;
		}

		var n = hit.Value.Normal.IsNearlyZero() ? Vector3.Up : hit.Value.Normal.Normal;
		return AddMapLightAt( hit.Value.HitPosition + n * 16f );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddMapLightAt( Vector3 at )
	{
		ActiveConfig.Current.Lights.Add( new MapLight
		{
			Position = at,
			Color = LightColor,
			Brightness = LightBrightness,
			Radius = LightRadius,
			Shadows = LightShadows,
			RequiresPower = LightRequiresPower,
		} );

		MapLightManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] light placed at {at}  {LightColor} x{LightBrightness:0.##}"
			+ $"  radius {LightRadius:0}"
			+ ( LightShadows ? "  ⚠ SHADOWS ON — the expensive kind" : "" )
			+ $"  ({ActiveConfig.Current.Lights.Count} total)" );
		return true;
	}

	/// <summary>
	/// Build a fog area from a drawn footprint.
	///
	/// ⛔ A DRAWN VOLUME, AND IT USED TO BE A SPHERE WITH A RADIUS. Asked for as *"not a radius, but
	/// instead like the walls, an area i can create"* — and the sphere was wrong for the job in a way
	/// worth recording: map spaces are rooms and corridors, and the only sphere that covers a corridor
	/// is one that also covers everything on the other side of its walls. Every area was either too
	/// small for the space or leaking into the next one.
	///
	/// ⚠️ SAME `MeasureBlock` AS THE WALLS AND THE DEBRIS, deliberately. The requirement is that it
	/// works exactly like the wall tool, and the only way that stays true is for both to run the same
	/// measurement rather than two copies that begin identical — the reason `BlockShape` exists at
	/// all.
	/// </summary>
	bool AddFogArea( BlockShape shape )
	{
		ActiveConfig.Current.Fog.Add( new FogArea
		{
			Position = shape.Position,
			Yaw = shape.Yaw,
			Size = shape.Size,
			Footprint = shape.Footprint,
			Color = FogColor,
			Density = FogDensity,
			Feather = FogFeather,
		} );

		FogAreaManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] fog area built {shape.Footprint.Count}-sided footprint,"
			+ $" {shape.Size.z:0} tall at {shape.Position} yaw {shape.Yaw:0}"
			+ $"  {FogColor} d{FogDensity:0.##}  feather {FogFeather:0}u"
			+ $"  ({ActiveConfig.Current.Fog.Count} total)" );

		return true;
	}

	/// <summary>Remove the fog area whose centre is nearest a point.</summary>
	bool RemoveFogAreaNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Fog;
		if ( list.Count == 0 ) return false;

		var best = -1;
		var bestD = float.MaxValue;

		for ( int i = 0; i < list.Count; i++ )
		{
			// ⚠️ MEASURED AGAINST THE BOX, NOT THE CENTRE. A fog area can be a long corridor, and
			// its centre can be fifty units from where you are standing inside it — aiming at the
			// part you can see and removing something across the map is the obvious failure.
			var half = list[i].Size * 0.5f;
			var local = list[i].Rotation.Inverse * ( at - list[i].Position );

			var d = new Vector3(
				MathF.Max( 0f, MathF.Abs( local.x ) - half.x ),
				MathF.Max( 0f, MathF.Abs( local.y ) - half.y ),
				MathF.Max( 0f, MathF.Abs( local.z ) - half.z ) ).Length;

			if ( d >= bestD ) continue;

			bestD = d;
			best = i;
		}

		if ( best < 0 || bestD > 256f )
		{
			Log.Info( "[nz] no fog area near there" );
			return false;
		}

		list.RemoveAt( best );
		FogAreaManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] removed fog area #{best}  ({list.Count} left)" );
		return true;
	}

	/// <summary>
	/// Record a room zone from a measured block — the fog area's and the walls' own measurement, so it is drawn exactly as they
	/// are. ⚠️ NO BODY: no collider, no nav, nothing drawn in a round. A zone is a name for a space, tested by containment
	/// (`RoomZone.Contains`) on every machine for its own player, and the host tells the others at once (`RoomZones.Sync`).
	/// </summary>
	bool AddRoomZone( BlockShape s )
	{
		var name = RoomZoneName?.Trim() ?? "";

		ActiveConfig.Current.RoomZones.Add( new RoomZone
		{
			Position = s.Position,
			Yaw = s.Yaw,
			Size = s.Size,
			Footprint = s.Footprint,
			Name = name,
		} );

		RoomZones.Sync();

		Log.Info( $"[nz] room zone #{ActiveConfig.Current.RoomZones.Count - 1} drawn " + Describe( s )
			+ (name.Length > 0 ? $"  '{name}'" : "  ⚠ NO NAME — it names nothing until it has one (nz_room_zone_name)") );
		return true;
	}

	/// <summary>Remove the room zone nearest a point — measured against its box, as the fog area's is.</summary>
	bool RemoveRoomZoneNear( Vector3 at )
	{
		var list = ActiveConfig.Current.RoomZones;
		if ( list.Count == 0 ) return false;

		var best = -1;
		var bestD = float.MaxValue;

		for ( int i = 0; i < list.Count; i++ )
		{
			var half = list[i].Size * 0.5f;
			var local = list[i].Rotation.Inverse * ( at - list[i].Position );

			var d = new Vector3(
				MathF.Max( 0f, MathF.Abs( local.x ) - half.x ),
				MathF.Max( 0f, MathF.Abs( local.y ) - half.y ),
				MathF.Max( 0f, MathF.Abs( local.z ) - half.z ) ).Length;

			if ( d >= bestD ) continue;

			bestD = d;
			best = i;
		}

		if ( best < 0 || bestD > 256f )
		{
			Log.Info( "[nz] no room zone near there" );
			return false;
		}

		var gone = list[best];
		list.RemoveAt( best );
		RoomZones.Sync();

		Log.Info( $"[nz] removed room zone #{best} '{gone.Name}'  ({list.Count} left)" );
		return true;
	}

	bool RemoveMapLightNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Lights;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;
			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no light near there" ); return false; }

		list.RemoveAt( best );
		MapLightManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] light removed ({list.Count} left)" );
		return true;
	}

	/// <summary>Place a looping sound where you are aiming.</summary>
	public bool AddSoundSpot()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at a surface" );
			return false;
		}

		var n = hit.Value.Normal.IsNearlyZero() ? Vector3.Up : hit.Value.Normal.Normal;
		return AddSoundSpotAt( hit.Value.HitPosition + n * 16f );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddSoundSpotAt( Vector3 at )
	{
		// ⚠️ PLACED EVEN WITH NO EVENT CHOSEN, and it says so rather than refusing. Positioning
		// first and picking the sound afterwards is a normal way to work, and a tool that silently
		// did nothing when the picker was empty would read as broken.
		if ( string.IsNullOrWhiteSpace( SoundEventPath ) )
			Log.Info( "[nz] no sound chosen yet — placed silent, pick one in the tool panel "
				+ "or with nz_sound_set" );

		ActiveConfig.Current.Sounds.Add( new SoundSpot
		{
			Position = at,
			Sound = SoundEventPath,
			Volume = SoundSpotVolume,
			Distance = SoundSpotDistance,
			Repeat = SoundSpotRepeat > 0f,
			RepeatMin = SoundSpotRepeat,
			RepeatMax = SoundSpotRepeat,
		} );

		SoundSpotManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] sound placed at {at}  '{SoundEventPath}'"
			+ $"  vol {SoundSpotVolume:0.##}  dist {SoundSpotDistance:0}"
			+ $"  ({ActiveConfig.Current.Sounds.Count} total)" );
		return true;
	}

	bool RemoveSoundSpotNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Sounds;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;
			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no sound near there" ); return false; }

		list.RemoveAt( best );
		SoundSpotManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] sound removed ({list.Count} left)" );
		return true;
	}

	/// <summary>
	/// Place a springboard where you are aiming, on the floor. Aimed inside one already placed, it gives that one the panel's
	/// strength, radius and landing rule instead: tuning a pad is a click, not a remove and a re-place.
	/// </summary>
	public bool AddSpringboard()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddSpringboardAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one, or re-set the one under the point, explicitly — so a command can drive it.</summary>
	public bool AddSpringboardAt( Vector3 at, Vector3 normal )
	{
		// ⚠️ The steep-surface refusal the floor machines here use: a pad on a wall throws nobody, and nothing about it says so.
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz] that surface is too steep for a springboard — aim at the floor" );
			return false;
		}

		var list = ActiveConfig.Current.Springboards ??= new();
		var strength = MathF.Max( 0f, SpringboardStrength );
		var radius = MathF.Max( 8f, SpringboardRadius );
		var up = SpringboardSystem.ApexHeight( strength, Scene );
		var landing = SpringboardSafeLanding ? "" : "  ⚠ fall damage on the landing";

		var i = SpringboardSystem.PadAt( list, at );
		if ( i >= 0 )
		{
			list[i].Strength = strength;
			list[i].Radius = radius;
			list[i].SafeLanding = SpringboardSafeLanding;

			Log.Info( $"[nz] springboard #{i} re-set — {strength:0} u/s, about {up:0} up, radius {radius:0}{landing}" );
			return true;
		}

		list.Add( new SpringboardSpot
		{
			Position = at,
			Strength = strength,
			Radius = radius,
			SafeLanding = SpringboardSafeLanding,
		} );

		Log.Info( $"[nz] springboard placed at {at} — {strength:0} u/s, about {up:0} up, radius {radius:0}{landing}"
			+ $"  ({list.Count} total)" );
		return true;
	}

	/// <summary>Remove the springboard under the crosshair: the one whose circle holds the point, else the nearest within 140.</summary>
	bool RemoveSpringboardNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Springboards;
		var best = SpringboardSystem.PadAt( list, at );

		// Not inside one: the nearest middle within 140, as the other point placeables here remove.
		if ( best < 0 && list is not null )
		{
			var bestDist = 140f;

			for ( int i = 0; i < list.Count; i++ )
			{
				var d = at.Distance( list[i].Position );
				if ( d >= bestDist ) continue;
				bestDist = d;
				best = i;
			}
		}

		if ( best < 0 ) { Log.Info( "[nz] no springboard near there" ); return false; }

		list.RemoveAt( best );
		Log.Info( $"[nz] springboard #{best} removed  ({list.Count} left)" );
		return true;
	}

	public bool AddAmmoBox()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddAmmoBoxAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddAmmoBoxAt( Vector3 at ) => AddAmmoBoxAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddAmmoBoxAt( Vector3 at, Vector3 normal )
	{
		// ⚠️ The same steep-surface refusal every machine here uses: one mounted sideways on a wall
		// looks like a placement that worked, and nothing about the result says you aimed at a wall.
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz] that surface is too steep for an ammo box — aim at the floor" );
			return false;
		}

		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.AmmoBoxes.Add( new AmmoBoxSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			Link = SpawnLink,
		} );
		AmmoBoxManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] ammo box placed at {at} facing {yaw:0}° "
			+ $"({ActiveConfig.Current.AmmoBoxes.Count} total)" );
		return true;
	}

	bool RemoveAmmoBoxNear( Vector3 at )
	{
		var list = ActiveConfig.Current.AmmoBoxes;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no ammo box near there" ); return false; }

		list.RemoveAt( best );
		AmmoBoxManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] ammo box removed ({list.Count} left)" );
		return true;
	}

	// ── Buyable ending, stamped at placement ─────────────────────────────────

	/// <summary>What the ending costs. The original's default.</summary>
	[Property] public int EndingPrice { get; set; } = 500;

	// ⛔ NO EDITOR FIELDS FOR model / keep-playing / reward-perks / perma-perks /
	// game-over text / power. The tool is deliberately three settings — price, prompt,
	// starting round — so these are no longer stamped at placement and each spot takes
	// its `EndingSpot` default (the teddy bear, and every flag off, which is an ending
	// that simply ends the run).
	//
	// ⚠️ THE FIELDS AND THEIR BEHAVIOUR STAY ON `EndingSpot`, and `BuyableEnding.Buy`
	// still honours them. They are reachable from a saved config and from a future row;
	// deleting working, tested behaviour to shorten a panel would be the wrong trade, and
	// a config that already carries them keeps working.

	/// <summary>The line on the use prompt.</summary>
	[Property] public string EndingHint { get; set; } = "End game";

	/// <summary>Earliest round it can be used.</summary>
	[Property] public int EndingStartRound { get; set; }

	// ── Pressable, stamped at placement ─────────────────────────────────────

	/// <summary>Flags that must ALL be set for the next pressable to be available.
	/// Comma-separated; blank = available from the start.</summary>
	[Property] public string EggRequired { get; set; } = "";

	/// <summary>Flags the next pressable SETS on completion. Comma-separated.</summary>
	[Property] public string EggReward { get; set; } = "";

	/// <summary>Flags that, if ANY is set, make the next pressable unavailable.</summary>
	[Property] public string EggExcluded { get; set; } = "";

	/// <summary>General-flag (door link) opened on completion. Blank = none.</summary>
	[Property] public string EggGeneralReward { get; set; } = "";

	/// <summary>Which STEP of the easter egg the next pressable belongs to. Everything sharing a
	/// number is one step and completes or resets as a unit. 0 = on its own.</summary>
	[Property] public int EggStepNumber { get; set; }

	/// <summary>Exact presses needed. 0 or 1 = a single press.</summary>
	[Property] public int EggRepeat { get; set; }

	/// <summary>Hold this long instead of tapping. 0 = a tap.</summary>
	[Property] public float PressHold { get; set; }

	/// <summary>How a failed attempt is locked out: "Seconds" or "Next round".
	///
	/// ⚠️ A STRING HERE, AN ENUM IN THE CONFIG, translated at placement — the same shape as
	/// `SpecialEnemy` and `TeleporterModel`. The tool panel's picker rows work in the label the
	/// mapper reads, and the saved map should not carry a display string.</summary>
	[Property] public string EggRetryMode { get; set; } = ToolSettings.RetryModes[0];

	/// <summary>Retry delay after a failed attempt. Only used when EggRetryMode is "Seconds".</summary>
	[Property] public float EggCooldown { get; set; }

	/// <summary>Seconds to finish the whole step once anyone starts it. 0 = no limit.</summary>
	[Property] public float EggWindow { get; set; }

	/// <summary>Becomes available this long after its Required flags are met.</summary>
	[Property] public float EggTimed { get; set; }

	/// <summary>Progress resets on the round turn.</summary>
	[Property] public bool EggResetOnRound { get; set; }

	/// <summary>Pressable from the crosshair.</summary>
	public bool AddPressable()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at a wall or the floor" );
			return false;
		}

		return AddPressableAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddPressableAt( Vector3 at ) => AddPressableAt( at, Vector3.Up );

	/// <summary>Place one with an explicit surface normal.
	///
	/// ⛔ NO STEEP-SURFACE REFUSAL — a button belongs on a WALL more often than a floor.</summary>
	public bool AddPressableAt( Vector3 at, Vector3 normal )
	{
		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		var spot = new PressableSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,

			// ⚠️ THE SHARED `SpawnLink`, not a Press-specific one. Every placeable in this
			// project stamps its door flag from the same editor field, so a mapper types a
			// flag once and places a spawner, a wall buy and a button behind the same door.
			Link = SpawnLink,

			HoldSeconds = PressHold,
		};

		StampEggConditions( spot );

		ActiveConfig.Current.Pressables.Add( spot );
		PressableManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] pressable placed at {at}"
			+ $"  flag {DoorLinks.Display( spot.Link )}"
			+ ( EggStepNumber > 0 ? $"  step {EggStepNumber}" : "" )
			+ $"  needs [{string.Join( ",", spot.Step.Required )}]"
			+ $"  gives [{string.Join( ",", spot.Step.Reward )}]"
			+ ( EggRepeat > 1 ? $"  x{EggRepeat}" : "" )
			+ ( PressHold > 0f ? $"  hold {PressHold:0.#}s" : "" )
			+ $" ({ActiveConfig.Current.Pressables.Count} total)" );
		return true;
	}

	/// <summary>The next clue's colour, opacity folded into its alpha.
	///
	/// ⛔ COLOUR AND OPACITY ARE TWO CONTROLS AND ONE VALUE. A mapper thinks "make it red"
	/// and "make it faint" separately, but `Color` carries alpha — so the hex sets RGB and
	/// the opacity slider overwrites A. Exposing an 8-digit hex instead would make
	/// transparency something you compute rather than something you drag.
	///
	/// ⚠️ AN UNPARSEABLE HEX FALLS BACK TO WHITE AND SAYS SO. Silently drawing black text on
	/// a dark wall is indistinguishable from the clue not rendering at all.</summary>
	Color ClueTint()
	{
		var c = Color.White;

		if ( !string.IsNullOrWhiteSpace( ClueColor ) )
		{
			if ( Color.TryParse( ClueColor, out var parsed ) ) c = parsed;
			else Log.Warning( $"[nz] clue colour '{ClueColor}' is not a hex like #ffeeb8 — using white" );
		}

		return c.WithAlpha( ClueOpacity.Clamp( 0f, 1f ) );
	}

	/// <summary>Comma-separated flag names, trimmed, blanks dropped.</summary>
	public static List<string> SplitFlags( string csv )
		=> string.IsNullOrWhiteSpace( csv )
			? new List<string>()
			: csv.Split( ',' ).Select( f => f.Trim() ).Where( f => f.Length > 0 ).ToList();

	bool RemovePressableNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Pressables;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no pressable near there" ); return false; }

		list.RemoveAt( best );
		PressableManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] pressable removed ({list.Count} left)" );
		return true;
	}

	/// <summary>Must be using this weapon to trigger an interactable. A substring of the class
	/// name, so "awm" matches "nz_awm". Blank = any.</summary>
	[Property] public string EggWeapon { get; set; } = "";

	/// <summary>Must own this perk to trigger an interactable. Blank = none needed.</summary>
	[Property] public string EggPerk { get; set; } = "";

	// ── Shootable, stamped at placement ────────────────────────────────

	/// <summary>The next shootable must be hit with a Pack-a-Punched weapon.</summary>
	[Property] public bool ShootRequiresPaP { get; set; }

	/// <summary>Shootable from the crosshair.</summary>
	public bool AddShootable()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at a wall or the floor" );
			return false;
		}

		return AddShootableAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddShootableAt( Vector3 at ) => AddShootableAt( at, Vector3.Up );

	/// <summary>Place one with an explicit surface normal.</summary>
	public bool AddShootableAt( Vector3 at, Vector3 normal )
	{
		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		var spot = new ShootableSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			Link = SpawnLink,
			RequiresPaP = ShootRequiresPaP,
		};

		StampEggConditions( spot );

		ActiveConfig.Current.Shootables.Add( spot );
		ShootableManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] shootable placed at {at}"
			+ $"  flag {DoorLinks.Display( spot.Link )}"
			+ ( EggStepNumber > 0 ? $"  step {EggStepNumber}" : "" )
			+ $"  needs [{string.Join( ",", spot.Step.Required )}]"
			+ $"  gives [{string.Join( ",", spot.Step.Reward )}]"
			+ ( ShootRequiresPaP ? "  PaP only" : "" )
			+ $" ({ActiveConfig.Current.Shootables.Count} total)" );
		return true;
	}

	bool RemoveShootableNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Shootables;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no shootable near there" ); return false; }

		list.RemoveAt( best );
		ShootableManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] shootable removed ({list.Count} left)" );
		return true;
	}

	/// <summary>
	/// Stamp the shared easter-egg conditions onto a freshly placed interactable.
	///
	/// ⛔ ONE METHOD BECAUSE THE FIELDS ARE SHARED. The tool panel edits one set of `Egg*`
	/// properties whichever interactable is selected — the same way every placeable in this
	/// project stamps its door flag from one `SpawnLink` — so a mapper types a step number and
	/// its flags ONCE and can then place a button and a shootable into that same step. Two
	/// parallel sets would have made building one step out of mixed parts a retyping exercise.
	/// </summary>
	void StampEggConditions( EggSpot spot )
	{
		spot.RepeatCount = EggRepeat;
		spot.CooldownSeconds = EggCooldown;
		spot.Retry = EggRetryMode == ToolSettings.RetryModes[1] ? EggRetry.NextRound : EggRetry.Seconds;
		spot.TimeWindow = EggWindow;
		spot.TimedDelay = EggTimed;
		spot.ResetOnRound = EggResetOnRound;
		spot.RequiredWeapon = EggWeapon ?? "";
		spot.RequiredPerk = EggPerk ?? "";

		// ⚠️ SPLIT FROM COMMA-SEPARATED TEXT because the tool panel has no list editor. Empty
		// entries are dropped so "a, b," does not register a nameless flag — a blank flag would be
		// permanently unset and would lock the step for ever with nothing on screen to say why.
		spot.Step.Required = SplitFlags( EggRequired );
		spot.Step.Reward = SplitFlags( EggReward );
		spot.Step.Excluded = SplitFlags( EggExcluded );
		spot.Step.GeneralReward = EggGeneralReward ?? "";
		spot.Step.StepNumber = EggStepNumber;
	}

	// ── Clue, stamped at placement ───────────────────────────────────────────

	/// <summary>What the next clue says.</summary>
	[Property] public string ClueText { get; set; } = "";

	/// <summary>Optional image path for the next clue.</summary>
	[Property] public string ClueImage { get; set; } = "";

	/// <summary>Panel size in world units. Big by default — see ClueSpot.Size.</summary>
	[Property] public Vector2 ClueSize { get; set; } = new( 1000f, 1000f );

	/// <summary>Text colour as hex, e.g. #ffeeb8. Blank or unparseable = white.</summary>
	[Property] public string ClueColor { get; set; } = "#ffeeb8";

	/// <summary>Text opacity, 0 (invisible) to 1 (solid).</summary>
	[Property] public float ClueOpacity { get; set; } = 1f;

	/// <summary>Text size, in panel units.</summary>
	[Property] public float ClueFontSize { get; set; } = 16f;

	/// <summary>Clue from the crosshair.</summary>
	public bool AddClue()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at a wall or the floor" );
			return false;
		}

		return AddClueAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddClueAt( Vector3 at ) => AddClueAt( at, Vector3.Up );

	/// <summary>Place one with an explicit surface normal.
	///
	/// ⛔ NO STEEP-SURFACE REFUSAL. A clue is a wall panel or a sign — a wall is its
	/// PRIMARY home, not an edge case, and refusing one would leave the tool able to place
	/// only the least useful kind.</summary>
	public bool AddClueAt( Vector3 at, Vector3 normal )
	{
		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.Clues.Add( new ClueSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			Text = ClueText,
			Image = ClueManager.ResolveImage( ClueImage ),          // a bare name saved as its clue-folder path
			Size = ClueSize,
			FontSize = ClueFontSize,
			Tint = ClueTint(),
		} );

		ClueManager.Ensure( Scene )?.Rebuild();

		var flat = normal.IsNearlyZero() || normal.Normal.z > 0.7f;

		Log.Info( $"[nz] clue placed at {at} · {( flat ? "flat on the floor" : "mounted to the surface" )}"
			+ ( string.IsNullOrWhiteSpace( ClueText ) ? "  (no text yet — nz_clue_text)" : $"  '{ClueText}'" )
			+ $" ({ActiveConfig.Current.Clues.Count} total)" );
		return true;
	}

	bool RemoveClueNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Clues;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no clue near there" ); return false; }

		list.RemoveAt( best );
		ClueManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] clue removed ({list.Count} left)" );
		return true;
	}

	/// <summary>Size of the placeholder box, in units.</summary>
	[Property] public Vector3 MiserySize { get; set; } = new( 32f, 32f, 48f );

	/// <summary>Misery device from the crosshair.</summary>
	public bool AddMisery()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddMiseryAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddMiseryAt( Vector3 at ) => AddMiseryAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddMiseryAt( Vector3 at, Vector3 normal )
	{
		// ⚠️ The same steep-surface refusal every standing machine here uses.
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz] that surface is too steep for a misery device — aim at the floor" );
			return false;
		}

		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.Miseries.Add( new MiserySpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			Size = MiserySize,
		} );

		MiseryDeviceManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] misery device placed at {at} facing {yaw:0}° "
			+ $"({ActiveConfig.Current.Miseries.Count} total)" );
		return true;
	}

	bool RemoveMiseryNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Miseries;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no misery device near there" ); return false; }

		list.RemoveAt( best );
		MiseryDeviceManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] misery device removed ({list.Count} left)" );
		return true;
	}

	/// <summary>Buyable ending from the crosshair.</summary>
	public bool AddEnding()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddEndingAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddEndingAt( Vector3 at ) => AddEndingAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddEndingAt( Vector3 at, Vector3 normal )
	{
		// ⛔ NO STEEP-SURFACE REFUSAL, UNLIKE EVERY OTHER MACHINE HERE. The Wunderfizz, the
		// Pack-a-Punch, the perk machines and the ammo box all refuse a wall, because each
		// is one authored prop that only reads correctly standing on the ground. The ending
		// is not a machine — it is whatever prop the map points it at, and the exit is as
		// likely to be a hatch, a radio or a panel on a wall as a bear on the floor.
		//
		// ⚠️ `BuyableEndingManager.OnSurface` is what makes this safe: it mounts flush and
		// faces OUT of a wall, and stands upright on a floor. Removing the refusal without
		// that would lay the prop on its side.
		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.Endings.Add( new EndingSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			Price = EndingPrice,
			Hint = EndingHint,
			StartRound = EndingStartRound,

			// ⚠️ `Link` IS STILL STAMPED even though the panel has no Flag row. `SpawnLink`
			// is SHARED editor state that every placeable reads, set from whichever tool
			// panel last showed it — so the flag a mapper set for a door still applies here,
			// exactly as it does for a wallbuy or an ammo box. Dropping the stamp would make
			// this the one placeable that silently ignores the flag they just set.
			Link = SpawnLink,
		} );

		BuyableEndingManager.Ensure( Scene )?.Rebuild();

		// ⚠️ SAYS WHICH SURFACE IT READ, because floor and wall place differently and the
		// only thing that decides is a normal nobody can see. "Placed" alone would leave a
		// mapper guessing why one bear stands up and the next is flat against a wall.
		var flat = normal.IsNearlyZero() || normal.Normal.z > 0.7f;

		Log.Info( $"[nz] buyable ending placed at {at} facing {yaw:0}° · {EndingPrice} points"
			+ $" · {( flat ? "upright on the floor" : "mounted flat to the surface" )}"
			+ $" ({ActiveConfig.Current.Endings.Count} total)" );
		return true;
	}

	bool RemoveEndingNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Endings;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no buyable ending near there" ); return false; }

		list.RemoveAt( best );
		BuyableEndingManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] buyable ending removed ({list.Count} left)" );
		return true;
	}

	// ══ building table ═════════════════════════════════════════
	//
	// ⚠️ A PARALLEL OF THE TRADING TABLE'S THREE METHODS, NOT A SHARED GENERIC ONE. They look
	// identical today and will not stay that way — a building table is going to want to know which
	// build it serves, and the one thing worse than two similar methods is one method with a flag
	// threaded through it for a case that only half applies.

	/// <summary>
	/// Which piece the build-part tool places next, 1 to 3.
	/// </summary>
	///
	/// ⚠️ A MODE ON THE TOOL RATHER THAN THREE TOOLS. The three are placed in one pass while
	/// walking a map, and three menu entries would mean three trips through the Q menu to lay out
	/// one puzzle. `nz_buildpart_set` changes it without leaving the map.
	public static int BuildPartNext { get; set; } = 1;

	/// <summary>Build part from the crosshair.</summary>
	public bool AddBuildPart()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz-build] aim at a surface" );
			return false;
		}

		return AddBuildPartAt( hit.Value.HitPosition );
	}

	public bool AddBuildPartAt( Vector3 at )
	{
		if ( !BuildParts.Valid( BuildPartNext ) )
		{
			Log.Info( $"[nz-build] part {BuildPartNext} is not 1..{BuildParts.Count}" );
			return false;
		}

		// ⚠️ NO FLOOR-NORMAL TILT, UNLIKE THE TABLES. A bench has to sit flat because it is
		// furniture; a dropped part looks better upright wherever it lands, and tilting it into a
		// slope mostly buries one end.
		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.BuildParts.Add( new BuildPartSpot
		{
			Position = at,
			Yaw = yaw,
			Part = BuildPartNext,
			Link = SpawnLink,
			RequiresPower = SpawnRequiresPower,
		} );
		BuildPartManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz-build] {BuildParts.Name( BuildPartNext )} placed at {at} "
			+ $"({ActiveConfig.Current.BuildParts.Count} part(s) total)" );
		return true;
	}

	bool RemoveBuildPartNear( Vector3 at )
	{
		var list = ActiveConfig.Current.BuildParts;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz-build] no build part near there" ); return false; }

		var was = BuildParts.Name( list[best].Part );
		list.RemoveAt( best );
		BuildPartManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz-build] {was} removed ({list.Count} left)" );
		return true;
	}

	/// <summary>
	/// Does a placed building table keep handing its weapon out. Off = one time.
	/// </summary>
	///
	/// ⚠️ A PLACEMENT DEFAULT ON THE EDITOR, like `SpawnLink` and `SpawnRequiresPower`. It is read
	/// once when the bench is placed and then belongs to that bench, so changing it here does not
	/// disturb tables already on the map.
	[Property] public bool BuildTablePermanent { get; set; }

	/// <summary>Building table from the crosshair.</summary>
	public bool AddBuildTable()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz-build] aim at the floor" );
			return false;
		}

		return AddBuildTableAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	public bool AddBuildTableAt( Vector3 at ) => AddBuildTableAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddBuildTableAt( Vector3 at, Vector3 normal )
	{
		// ⚠️ THE SAME 0.7 THE TRADING TABLE USES — about 45°. A bench on a steeper face than
		// that leans far enough to look placed by accident, and the two sharing a mesh should
		// share the limit that mesh implies.
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz-build] that surface is too steep for a building table — aim at the floor" );
			return false;
		}

		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.BuildTables.Add( new BuildTableSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			Link = SpawnLink,
			RequiresPower = SpawnRequiresPower,
			Permanent = BuildTablePermanent,
		} );
		BuildTableManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz-build] building table placed at {at} facing {yaw:0}° "
			+ $"({ActiveConfig.Current.BuildTables.Count} total)" );
		return true;
	}

	bool RemoveBuildTableNear( Vector3 at )
	{
		var list = ActiveConfig.Current.BuildTables;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz-build] no building table near there" ); return false; }

		list.RemoveAt( best );
		BuildTableManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz-build] building table removed ({list.Count} left)" );
		return true;
	}

	/// <summary>Trading table from the crosshair.</summary>
	public bool AddTradeTable()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddTradeTableAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddTradeTableAt( Vector3 at ) => AddTradeTableAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddTradeTableAt( Vector3 at, Vector3 normal )
	{
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz] that surface is too steep for a trading table — aim at the floor" );
			return false;
		}

		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.TradeTables.Add( new TradeTableSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			Link = SpawnLink,
		} );
		TradeTableManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] trading table placed at {at} facing {yaw:0}° "
			+ $"({ActiveConfig.Current.TradeTables.Count} total)" );
		return true;
	}

	bool RemoveTradeTableNear( Vector3 at )
	{
		var list = ActiveConfig.Current.TradeTables;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no trading table near there" ); return false; }

		list.RemoveAt( best );
		TradeTableManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] trading table removed ({list.Count} left)" );
		return true;
	}

	// ── Arsenal options, stamped at placement ─────────────────────────────
	//
	// ⚠️ STAMPED AT PLACEMENT, like every other placeable's options. Editing these
	// changes what the NEXT machine is placed with and leaves existing ones alone —
	// which is what a mapper expects from a tool panel, and is why the config stores
	// the values per spot rather than reading them live.

	/// <summary>Salvage for armor tier 1. ARSENAL_REMAKE.md §4.3 locks 700/2000/5000.</summary>
	[Property] public int ArsenalTier1Price { get; set; } = 700;

	[Property] public int ArsenalTier2Price { get; set; } = 2000;

	[Property] public int ArsenalTier3Price { get; set; } = 5000;

	[Property] public int ArsenalStartRound { get; set; }

	[Property] public bool ArsenalRequiresPower { get; set; } = true;

	/// <summary>Arsenal from the crosshair. Mirrors AddWunderfizz.</summary>
	public bool AddArsenal()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor" );
			return false;
		}

		return AddArsenalAt( hit.Value.HitPosition, hit.Value.Normal );
	}

	/// <summary>Place one explicitly, so a command can drive it.</summary>
	public bool AddArsenalAt( Vector3 at ) => AddArsenalAt( at, Vector3.Up );

	/// <summary>Place one with an explicit floor normal, so it can sit on a slope.</summary>
	public bool AddArsenalAt( Vector3 at, Vector3 normal )
	{
		// ⚠️ Same steep-surface refusal as the other machines: one mounted sideways
		// on a wall looks like a placement that worked, and nothing about the result
		// says you aimed at a wall.
		if ( !normal.IsNearlyZero() && normal.Normal.z <= 0.7f )
		{
			Log.Info( "[nz] that surface is too steep for an Arsenal — aim at the floor" );
			return false;
		}

		var yaw = (PlacerPosition() - at).WithZ( 0 ).EulerAngles.yaw;

		ActiveConfig.Current.Arsenals.Add( new ArsenalSpot
		{
			Position = at,
			Yaw = yaw,
			Normal = normal.IsNearlyZero() ? Vector3.Up : normal.Normal,
			ArmorTier1Price = ArsenalTier1Price,
			ArmorTier2Price = ArsenalTier2Price,
			ArmorTier3Price = ArsenalTier3Price,
			StartRound = ArsenalStartRound,
			RequiresPower = ArsenalRequiresPower,
			Link = SpawnLink,
		} );
		ArsenalManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] Arsenal placed at {at} facing {yaw:0}° — armor "
			+ $"{ArsenalTier1Price}/{ArsenalTier2Price}/{ArsenalTier3Price} salvage "
			+ $"({ActiveConfig.Current.Arsenals.Count} total)" );
		return true;
	}

	/// <summary>Remove the Arsenal nearest a point. Right-click, via RemoveAimed.</summary>
	bool RemoveArsenalNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Arsenals;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no Arsenal near there" ); return false; }

		list.RemoveAt( best );
		ArsenalManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] Arsenal removed ({list.Count} left)" );
		return true;
	}

	/// <summary>Remove the Wunderfizz nearest a point. Right-click, via RemoveAimed.</summary>
	bool RemoveWunderfizzNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Wunderfizzes;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			var d = at.Distance( list[i].Position );
			if ( d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no Wunderfizz near there" ); return false; }

		list.RemoveAt( best );
		WunderfizzManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] Wunderfizz removed ({list.Count} left)" );
		return true;
	}

	/// <summary>Remove the machine nearest a point. Right-click, via RemoveAimed.</summary>
	bool RemovePackAPunchNear( Vector3 at )
	{
		var list = ActiveConfig.Current.PackAPunches;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 140f;

		for ( int i = 0; i < list.Count; i++ )
		{
			float d = list[i].Position.Distance( at );
			if ( d >= bestDist ) continue;
			bestDist = d;
			best = i;
		}

		if ( best < 0 ) return false;

		list.RemoveAt( best );
		PackAPunchManager.Ensure( Scene )?.Rebuild();
		Log.Info( $"[nz] Pack-a-Punch removed ({list.Count} left)" );
		return true;
	}

	/// <summary>Where the author is standing, for facing placed objects at them.</summary>
	Vector3 PlacerPosition()
	{
		var p = NZPlayer.Local;
		return p.IsValid() ? p.WorldPosition : WorldPosition;
	}

	// ── barricades ───────────────────────────────────────────────────────────

	/// <summary>
	/// Click one, click two, done.
	///
	/// ⚠️ Reuses `_corners` rather than adding a second list — ResetCorners (R)
	/// and the pending-count readout then work on barricades for free, and there
	/// is only ever one pending shape because only one tool is active.
	/// </summary>
	public bool AddBarricadePoint()
	{
		// ⚠️ AimTrace, the same helper AddCorner uses — it returns null when the
		// crosshair is on nothing, which must be reported rather than silently
		// dropping a point at the origin.
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the floor or a wall" );
			return false;
		}

		return AddBarricadePointAt( hit.Value.HitPosition );
	}

	/// <summary>Add a barricade point explicitly, so a command can drive it.</summary>
	public bool AddBarricadePointAt( Vector3 at )
	{
		_corners.Add( at );

		if ( _corners.Count < 2 )
		{
			Log.Info( $"[nz] barricade point 1/2 at {at} — click the other end" );
			return true;
		}

		var a = _corners[0];
		var b = _corners[1];
		_corners.Clear();
		_awaitingHeight = false;

		// ⚠️ Rejected rather than clamped. A zero-length barricade is a click
		// someone did not mean to make, and building a degenerate one that cannot
		// be seen is worse than saying so.
		if ( a.Distance( b ) < 8f )
		{
			Log.Warning( "[nz] those two points are basically the same spot — "
				+ "a barricade needs a run. Points cleared; start again." );
			return false;
		}

		var spot = new BarricadeSpot
		{
			A = a,
			B = b,
			Height = MathF.Max( 8f, BarricadeHeight ),
			Boards = Math.Clamp( BarricadeBoards, 0, Barricade.MaxPlanks ),
		};
		ActiveConfig.Current.Barricades.Add( spot );
		BarricadeManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] barricade built — {spot.Length:0}u long, {spot.Height:0}u tall, "
			+ $"{spot.Boards} boards ({ActiveConfig.Current.Barricades.Count} total)" );
		return true;
	}

	/// <summary>Nav link point from the crosshair. Mirrors AddBarricadePoint.</summary>
	public bool AddNavLinkPoint()
	{
		var hit = AimTrace();
		if ( hit is null )
		{
			Log.Info( "[nz] nothing under the crosshair — aim at the ledge, then the floor" );
			return false;
		}

		return AddNavLinkPointAt( hit.Value.HitPosition );
	}

	/// <summary>
	/// Second click finishes a nav link: A is where you stand on the LEDGE, B is
	/// where you want them to land.
	///
	/// ⚠️ Deliberately NOT floor-dropped like a barricade corner. The whole point
	/// of a drop link is that its two ends are at different heights, and snapping
	/// both to the floor under the crosshair would collapse every link to a
	/// horizontal one — which the mesh could already path anyway.
	/// </summary>
	public bool AddNavLinkPointAt( Vector3 at )
	{
		// One click, no second point: the landing is traced for. See
		// NavLinkManager.BuildDrop.
		if ( NavLinkDropMode )
		{
			_corners.Clear();
			return NavLinkManager.BuildDrop( at, NavLinkRadius, walk: NavLinkWalk ) is not null;
		}

		_corners.Add( at );

		if ( _corners.Count < 2 )
		{
			Log.Info( $"[nz] nav link A at {at} — now click where they come OUT" );
			return true;
		}

		var a = _corners[0];
		var b = _corners[1];
		_corners.Clear();
		_awaitingHeight = false;

		if ( a.Distance( b ) < 8f )
		{
			Log.Warning( "[nz] both ends are the same spot — a link needs to go "
				+ "somewhere. Points cleared; start again." );
			return false;
		}

		if ( !NavLinkJump && !NavLinkDrop )
		{
			Log.Warning( "[nz] both Jump and Drop are off — that link would do nothing. "
				+ "Turn at least one on." );
			return false;
		}

		// ⚠️ LOW AND HIGH FROM THE GEOMETRY, not from click order. "Jump" means
		// bottom to top and "drop" means top to bottom no matter which end you
		// clicked first — clicking the ledge before the floor must not silently
		// invert what the toggles mean.
		var low = a.z <= b.z ? a : b;
		var high = a.z <= b.z ? b : a;

		var r = MathF.Max( 8f, NavLinkRadius );
		int made = 0;

		// ⛔ TWO SEPARATE ONE-WAY LINKS, NEVER ONE BIDIRECTIONAL ONE. The per-agent
		// gate forbids by AREA and an area has no direction, so a two-way link
		// tagged "jump" is also the way down and closing it closes both. Jump and
		// drop have to be independently gateable, which means separate links.
		if ( NavLinkJump )
		{
			ActiveConfig.Current.NavLinks.Add( new NavLinkSpot
			{
				A = low, B = high, BiDirectional = false, Radius = r, Link = SpawnLink,
				Walk = NavLinkWalk,
			} );
			made++;
		}

		if ( NavLinkDrop )
		{
			ActiveConfig.Current.NavLinks.Add( new NavLinkSpot
			{
				A = high, B = low, BiDirectional = false, Radius = r, Link = SpawnLink,
				Walk = NavLinkWalk,
			} );
			made++;
		}

		NavLinkManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] nav link: {high.z - low.z:0}u apart — "
			+ (NavLinkJump ? "JUMP up" : "") + (NavLinkJump && NavLinkDrop ? " + " : "")
			+ (NavLinkDrop ? "DROP down" : "")
			+ (NavLinkWalk ? "  ·  WALK (no clip, normal speed)" : "")
			+ $" ({made} link(s), {ActiveConfig.Current.NavLinks.Count} total)" );
		return true;
	}

	/// <summary>Remove the nav link nearest a point. Right-click, via RemoveAimed.</summary>
	bool RemoveNavLinkNear( Vector3 at )
	{
		var spot = NavLinkManager.Nearest( at );
		if ( spot is null ) { Log.Info( "[nz] no nav link near there" ); return false; }

		ActiveConfig.Current.NavLinks.Remove( spot );
		NavLinkManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] nav link removed ({ActiveConfig.Current.NavLinks.Count} left)" );
		return true;
	}

	/// <summary>Remove the barricade nearest a point. Right-click, via RemoveAimed.</summary>
	bool RemoveBarricadeNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Barricades;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = 120f;      // same generous grab radius the other removers use

		for ( int i = 0; i < list.Count; i++ )
		{
			float d = list[i].Centre.Distance( at );
			if ( d >= bestDist ) continue;
			bestDist = d;
			best = i;
		}

		if ( best < 0 ) return false;

		list.RemoveAt( best );
		BarricadeManager.Ensure( Scene )?.Rebuild();
		Log.Info( $"[nz] barricade removed ({list.Count} left)" );
		return true;
	}

	bool AddDebris( BlockShape s )
	{
		if ( DoorLinks.IsUnlinked( DebrisLink ) )
			Log.Warning( "[nz] blank/0 is always-open — this barrier will gate nothing" );

		ActiveConfig.Current.Debris.Add( new Debris
		{
			Position = s.Position,
			Yaw = s.Yaw,
			Size = s.Size,
			Footprint = s.Footprint,
			Material = DebrisMaterial,
			Visible = DebrisVisible,
			Link = DebrisLink,
			Price = DebrisPrice,
			RequiresPower = DebrisRequiresPower,
			Buyable = DebrisBuyable,
		} );

		Log.Info( $"[nz] debris #{ActiveConfig.Current.Debris.Count - 1} built "
			+ Describe( s )
			+ $"  link {DebrisLink}, {DebrisCostWord}"
			+ (DebrisRequiresPower ? "  needs power" : "") );

		DebrisManager.Ensure( Scene )?.Rebuild();
		return true;
	}

	/// <summary>
	/// Record an invisible wall from a measured block.
	///
	/// ⚠️ NO LINK, NO PRICE, NO POWER — and no warning about any of them. Those
	/// three are the whole difference between this and AddDebris; everything
	/// above this point is shared.
	/// </summary>
	bool AddDamageWall( BlockShape s )
	{
		ActiveConfig.Current.DamageWalls.Add( new DamageWall
		{
			Position = s.Position,
			Yaw = s.Yaw,
			Size = s.Size,
			Footprint = s.Footprint,
			Damage = DamageWallDamage,
			Interval = DamageWallInterval,
			Visible = DamageWallVisible,
			VisibleInGame = DamageWallShown,
			Material = DamageWallMaterial,
		} );

		// ⚠️ PRINTS THE RATE AS WELL AS THE PAIR. "20 every 1s" and "200 every 10s" are the
		// same 20/s and completely different walls to walk through, so the derived number
		// alone would mislead — but without it nobody compares two walls at a glance.
		var dps = DamageWallInterval > 0f ? DamageWallDamage / DamageWallInterval : 0f;

		Log.Info( $"[nz] damage wall #{ActiveConfig.Current.DamageWalls.Count - 1} built "
			+ Describe( s )
			+ $"  {DamageWallDamage:0.#} dmg every {DamageWallInterval:0.##}s (~{dps:0.#}/s)"
			+ "  NO COLLISION  ·  "
			// ⚠️ SAYS WHICH OF THE TWO IT IS. "drawn in creative only" was true of every damage
			// wall ever built and is now true of only some, so a line that still claimed it would
			// be the one place a mapper checks to find out what they just made.
			+ ( DamageWallShown
				? $"VISIBLE IN GAME, {DamageWallMaterial}"
				: "drawn in creative only" ) );

		DamageWallManager.Ensure( Scene )?.Rebuild();
		return true;
	}

	bool RemoveDamageWallNear( Vector3 at )
	{
		var list = ActiveConfig.Current.DamageWalls;
		if ( list.Count == 0 ) return false;

		int best = -1;
		float bestDist = float.MaxValue;

		for ( int i = 0; i < list.Count; i++ )
		{
			// ⚠️ MEASURED AGAINST THE BOX, not a fixed radius. A damage wall can be a long
			// corridor; a radius that worked for a small one would make a big one
			// unselectable except from its centre.
			var d = at.Distance( list[i].Position );
			var reach = MathF.Max( 64f, list[i].Size.Length * 0.5f );

			if ( d > reach || d >= bestDist ) continue;

			bestDist = d;
			best = i;
		}

		if ( best < 0 ) { Log.Info( "[nz] no damage wall near there" ); return false; }

		list.RemoveAt( best );
		DamageWallManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] damage wall removed ({list.Count} left)" );
		return true;
	}

	bool AddInvisibleWall( BlockShape s )
	{
		ActiveConfig.Current.InvisibleWalls.Add( new InvisibleWall
		{
			Position = s.Position,
			Yaw = s.Yaw,
			Size = s.Size,
			Footprint = s.Footprint,
			Visible = WallVisible,
			Material = WallMaterial,
			BlocksZombies = WallBlocksZombies,
		} );

		Log.Info( $"[nz] invisible wall #{ActiveConfig.Current.InvisibleWalls.Count - 1} built "
			+ Describe( s )
			+ (WallVisible ? "  VISIBLE" : "  invisible")
			// ⛔ THE OLD TEXT SAID "does not block nav" UNCONDITIONALLY, which is now a lie
			// half the time — and it is the one line that tells the builder what they just
			// made. A stale confirmation is worse than none: it is read as verification.
			+ (WallBlocksZombies
				? "  solid · BLOCKS ZOMBIES (baked into the navmesh)"
				: "  solid · player-only, horde walks through") );

		InvisibleWallManager.Ensure( Scene )?.Rebuild();
		return true;
	}

	/// <summary>Shared one-line description of a built block, so debris and walls
	/// report themselves in the same words.</summary>
	static string Describe( BlockShape s )
		=> (s.Footprint.Count >= 3
				? $"{s.Footprint.Count}-sided footprint, {s.Size.z:0} tall"
				: $"{s.Size.x:0}x{s.Size.y:0}x{s.Size.z:0}")
			+ $" at {s.Position} yaw {s.Yaw:0}";

	/// <summary>Remove the barrier nearest the crosshair. Wider radius than a
	/// spawn marker because a barrier's origin can sit well inside a big
	/// model — you aim at the wall, not at its pivot.</summary>
	bool RemoveDebrisNear( Vector3 at )
	{
		var list = ActiveConfig.Current.Debris;

		var found = list
			.Select( ( d, i ) => (d, i, dist: d.Position.Distance( at )) )
			.Where( x => x.dist <= RemoveRadius * 2f )
			.OrderBy( x => x.dist )
			.FirstOrDefault();

		if ( found.d is null )
		{
			Log.Info( "[nz] no debris under the crosshair" );
			return false;
		}

		list.RemoveAt( found.i );
		DebrisManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] debris removed  ({list.Count} left)" );
		return true;
	}

	/// <summary>
	/// Remove the invisible wall under the crosshair: the one the ray hit, or else the one
	/// whose centre marker is nearest the aim point.
	///
	/// ⛔ THE WALL THE RAY HIT COMES FIRST. This used to test only the distance from the aim
	/// point to each wall's centre, which works on a small wall and never on a big one: the
	/// aim trace is stopped by a wall's collider, so the point lands on the wall's FACE, and
	/// on a 550-wide block that face is 275 units from a centre the test wanted within 96.
	/// The wall took every click — corners landed on its faces — while RMB answered "no
	/// invisible wall under the crosshair" dozens of times. (city_uprising_d, 2026-09-26.)
	///
	/// ⚠️ THE MARKER STAYS AS THE FALLBACK, for what the old test was for: a wall you cannot
	/// see, removed by aiming at the marker drawn at its centre. Same wide radius as debris,
	/// and for the same reason: you aim at the wall, not at its pivot.
	///
	/// ⚠️ A WALL NEITHER CAN REACH — one you are standing inside — goes by number:
	/// nz_wall_list, then nz_wall_remove &lt;number&gt;.
	/// </summary>
	bool RemoveWallNear( Vector3 at, GameObject aimed = null )
	{
		var list = ActiveConfig.Current.InvisibleWalls;

		var m = InvisibleWallManager.Instance;
		var index = m.IsValid() ? m.IndexOfObject( aimed ) : -1;

		if ( index < 0 || index >= list.Count )
		{
			var found = list
				.Select( ( w, i ) => (w, i, dist: w.Position.Distance( at )) )
				.Where( x => x.dist <= RemoveRadius * 2f )
				.OrderBy( x => x.dist )
				.FirstOrDefault();

			if ( found.w is null )
			{
				Log.Info( "[nz] no invisible wall under the crosshair"
					+ (list.Count > 0 ? "  (nz_wall_list numbers them, nz_wall_remove <number> removes one)" : "") );
				return false;
			}

			index = found.i;
		}

		var gone = list[index];
		list.RemoveAt( index );
		InvisibleWallManager.Ensure( Scene )?.Rebuild();

		Log.Info( $"[nz] invisible wall #{index} removed: {WallCommands.Shape( gone )}  ({list.Count} left)" );
		return true;
	}

	/// <summary>The config list a tool edits. One lookup so placing, removing
	/// and drawing can never disagree about which list a tool owns.</summary>
	static System.Collections.Generic.List<SpawnPoint> ListFor( string tool ) => tool switch
	{
		NZTools.PlayerSpawn => ActiveConfig.Current.PlayerSpawns,
		NZTools.ZombieSpawn => ActiveConfig.Current.ZombieSpawns,
		NZTools.SpecialSpawn => ActiveConfig.Current.SpecialSpawns,
		NZTools.BossSpawn => ActiveConfig.Current.BossSpawns,
		_ => null,
	};

	/// <summary>
	/// Remove the power switch nearest the crosshair.
	///
	/// ⚠️ Removing the LAST switch turns the map's power back ON, because
	/// Power.IsOn is derived from whether one exists. Said out loud, or deleting
	/// a prop silently unlocks every door that was gated behind it.
	/// </summary>
	/// <summary>
	/// Delete the wallbuy nearest the aim point.
	///
	/// ⚠️ NEAREST WITHIN A RADIUS, not "whatever the ray struck". A wallbuy's
	/// display model is small and often flush to a wall, so an exact-hit test
	/// makes RMB feel broken whenever you clip the wall behind it instead — the
	/// same reason the debris and switch removers work this way.
	/// </summary>
	bool RemoveWallBuyNear( Vector3 at )
	{
		var mgr = WallBuyManager.Ensure();
		if ( mgr is null ) return false;

		var found = mgr.All
			.Select( b => (b, d: b.WorldPosition.Distance( at )) )
			.Where( x => x.d <= RemoveRadius )
			.OrderBy( x => x.d )
			.ToList();

		if ( found.Count == 0 ) return false;

		var hit = found[0].b;

		// ⛔ THE CONFIG ENTRY TOO, NOT JUST THE OBJECT. Destroying the scene object
		// alone made the wallbuy come back on the next rebuild, which is worse
		// than not deleting it — it looks removed until something reloads.
		//
		// ⚠️ Matched by POSITION rather than by identity: the config holds data,
		// not components, so there is no reference between the two.
		var cfg = ActiveConfig.Current.WallBuys;
		var idx = cfg.FindIndex( w => w.Position.Distance( hit.WorldPosition ) < 4f );
		if ( idx >= 0 ) cfg.RemoveAt( idx );

		Log.Info( $"[nz] weapon buy removed: {hit.WeaponName} @ {hit.Price}"
			+ $"  ({cfg.Count} left)" );

		hit.GameObject.Destroy();
		return true;
	}

	bool RemoveSwitchNear( Vector3 at )
	{
		var list = ActiveConfig.Current.PowerSwitches;

		var found = list
			.Select( ( s, i ) => (s, i, d: s.Position.Distance( at )) )
			.Where( x => x.d <= RemoveRadius * 2f )
			.OrderBy( x => x.d )
			.FirstOrDefault();

		if ( found.s is null )
		{
			Log.Info( "[nz] no power switch under the crosshair" );
			return false;
		}

		var wasOn = Power.IsOn;
		list.RemoveAt( found.i );
		PowerManager.Instance?.Rebuild();

		Log.Info( $"[nz] power switch #{found.i} removed ({list.Count} left)" );

		if ( !wasOn && Power.IsOn )
			Log.Info( "[nz] ⚠ that was the LAST switch — power is now permanently ON" );

		return true;
	}

	// ── markers ──────────────────────────────────────────────────────────────

	/// <summary>
	/// How long one drawing of the placed markers lasts, in seconds — and so how often they are all drawn again: ten
	/// times a second, not every frame.
	///
	/// ⛔ EVERY OVERLAY DRAW IS A NEW NATIVE OBJECT — a `SceneDynamicObject` for a line or a box, a sphere, a text, a model —
	/// and each takes a handle from the engine's `HandleIndex`, a counter that only goes up and never gives one back.
	/// Drawn every frame, basalt's markers took ~2,000 a frame, and on 2026-09-26, after six and a half hours in the editor,
	/// the counter passed int.MaxValue: from then every new collider, light and model failed to create ("Interop Object
	/// should not be null", "Unable to create mesh shape") and every freed one logged "FreeHandle is -2147…", thousands a
	/// frame, at 1 fps. Only a restart recovers. `nz_handles` (the editor's `HandleCensus`) counts them.
	///
	/// ⚠️ SEAMLESS BECAUSE IT KEEPS THE OVERLAY'S OWN CLOCK. A timed draw starts with exactly this life, and at the start of
	/// each update the overlay takes `Time.Delta` off it and removes it once below zero (`DebugOverlaySystem.RemoveExpired`).
	/// `_markerLife` makes the same subtraction every frame, so the frame the old drawing goes is the frame the new one is
	/// made: no gap, and no second copy doubling the see-through spheres and boxes.
	/// </summary>
	const float MarkerLife = 0.1f;

	/// <summary>The life left on the markers drawn last, counted down with the overlay's (<see cref="MarkerLife"/>). Below zero: draw now.</summary>
	float _markerLife = -1f;

	/// <summary>
	/// Draw every placed marker.
	///
	/// Debug overlay rather than models: no assets needed, and each placeable
	/// type gets its own colour AND shape, which is what makes a map full of
	/// mixed markers readable. Creative only — these never render in a round.
	/// </summary>
	void DrawMarkers()
	{
		var d = Scene.DebugOverlay;
		if ( d is null ) return;

		// ⚠️ EVERY DRAW BELOW LASTS `MarkerLife`, NOT ONE FRAME: they are drawn again only when it runs out.

		// Same person-sized box for both, distinguished by COLOUR — blue for
		// player, green for zombie. Both mark where a body will stand, so a
		// shared silhouette reads as "someone spawns here" and the colour says
		// who. It also shows the footprint honestly: a sphere did not.
		// ⛔ EACH PASS IS ISOLATED. These used to be eight bare calls in a row, so
		// an exception in ANY of them silently took every pass BELOW it with it —
		// and which markers vanished depended only on which pass threw first.
		// That is the whole of "sometimes the orange debris nodes are not there,
		// sometimes it is the wall tool": one fault, presenting differently
		// depending on the order.
		//
		// The comment on DrawPendingCorners already records this exact failure
		// being found once. Fixing it for one caller and leaving the other seven
		// in a shared fate-line is why it came back.
		//
		// ⚠️ AND IT REPORTS. A silent catch would turn "everything vanishes" into
		// "one thing vanishes", which is better but still blind — the log names
		// the pass and the exception, so the real cause is one line away instead
		// of another three-guess hunt.
		Safe( "spawns/player", () => DrawSpawns( d, ActiveConfig.Current.PlayerSpawns, NZTools.PlayerSpawn ) );
		Safe( "spawns/zombie", () => DrawSpawns( d, ActiveConfig.Current.ZombieSpawns, NZTools.ZombieSpawn ) );
		Safe( "spawns/special", () => DrawSpawns( d, ActiveConfig.Current.SpecialSpawns, NZTools.SpecialSpawn ) );
		Safe( "spawns/boss", () => DrawSpawns( d, ActiveConfig.Current.BossSpawns, NZTools.BossSpawn ) );
		Safe( "spawn links", () => DrawSpawnLinks( d ) );
		Safe( "debris", () => DrawDebris( d ) );
		Safe( "invisible walls", () => DrawInvisibleWalls( d ) );
		Safe( "switches", () => DrawSwitches( d ) );
		Safe( "nav links", () => DrawNavLinks( d ) );
		Safe( "teleporters", () => DrawTeleporters( d ) );
		Safe( "springboards", () => DrawSpringboards( d ) );
		Safe( "soul boxes", () => DrawSoulBoxes( d ) );

		// ⛔ THE THREE MAP OBJECTS HAD NO MARKER AT ALL, AND THAT IS WHY THEY COULD NOT BE FOUND.
		// Reported as "sometimes i cannot see the nodes i place" — and "sometimes" was the shape of
		// it: every OTHER placeable draws, so the gap looked intermittent rather than total. A light
		// is findable only because it emits light, and a dim one is not; a sound spot and a fog area
		// emit nothing a camera can see, so they were invisible in the one mode built for placing
		// them. Nothing was broken about the placement — there was simply nothing to look at.
		Safe( "lights", () => DrawMapLights( d ) );
		Safe( "sound spots", () => DrawSoundSpots( d ) );
		Safe( "fog areas", () => DrawFogAreas( d ) );
		Safe( "room zones", () => DrawRoomZones( d ) );
	}

	/// <summary>
	/// Placed lights — a small ball at the source, a wire sphere at the reach.
	///
	/// ⚠️ THE REACH IS THE POINT, NOT THE POSITION. A light's radius is the number that decides
	/// whether it does anything where you wanted it to, and it is the one thing you cannot judge by
	/// looking at the lit room — an over-large radius looks the same as a correct one until it
	/// washes a neighbouring space.
	/// </summary>
	void DrawMapLights( DebugOverlaySystem d )
	{
		var list = ActiveConfig.Current.Lights;

		for ( int i = 0; i < list.Count; i++ )
		{
			var l = list[i];

			// Its own colour, so a swatch mistake is visible on the marker rather than only in the
			// lighting — a light authored black is otherwise indistinguishable from one not built.
			var col = Color.Parse( l.Color ) ?? Color.White;

			d.Sphere( new Sphere( l.Position, 10f ), col, MarkerLife, global::Transform.Zero, false );
			d.Sphere( new Sphere( l.Position, l.Radius ), col.WithAlpha( 0.06f ), MarkerLife,
				global::Transform.Zero, false );

			d.Text( l.Position + Vector3.Up * 20f,
				$"#{i} light  ·  {l.Color} x{l.Brightness:0.##}  ·  {l.Radius:0}u"
					+ ( l.Shadows ? "  ·  ⚠ SHADOWS" : "" )
					+ ( l.RequiresPower ? "  ·  power" : "" ), duration: MarkerLife );
		}
	}

	/// <summary>
	/// Placed looping sounds — a ball at the emitter, a wire sphere at the falloff.
	///
	/// ⚠️ AND THE CAP MAKES THE SPHERE MATTER MORE THAN IT LOOKS. Only the nearest few spots are
	/// ever audible, so two placed close together are mostly one spot with a spare — which is
	/// obvious drawn and invisible otherwise.
	/// </summary>
	void DrawSoundSpots( DebugOverlaySystem d )
	{
		var col = NZTools.ColorFor( NZTools.SoundSpot );
		var list = ActiveConfig.Current.Sounds;

		for ( int i = 0; i < list.Count; i++ )
		{
			var sp = list[i];

			d.Sphere( new Sphere( sp.Position, 10f ), col, MarkerLife, global::Transform.Zero, false );
			d.Sphere( new Sphere( sp.Position, sp.Distance ), col.WithAlpha( 0.05f ), MarkerLife,
				global::Transform.Zero, false );

			var name = string.IsNullOrWhiteSpace( sp.Sound )
				? "⚠ NO SOUND SET"
				: sp.Sound.Split( '/' )[^1].Replace( ".sound", "" );

			d.Text( sp.Position + Vector3.Up * 20f,
				$"#{i} sound  ·  {name}\nvol {sp.Volume:0.##}  ·  {sp.Distance:0}u", duration: MarkerLife );
		}
	}

	/// <summary>
	/// Drawn fog areas — the outline of the prism, and how deep the fade runs.
	///
	/// ⛔ THIS IS THE ONLY WAY TO SEE ONE FROM OUTSIDE. The fog itself is a global GradientFog
	/// blended by where you stand, so an area is genuinely invisible until you walk into it — the
	/// marker is not a convenience here, it is the whole of the authoring feedback.
	/// </summary>
	void DrawFogAreas( DebugOverlaySystem d )
	{
		var list = ActiveConfig.Current.Fog;

		for ( int i = 0; i < list.Count; i++ )
		{
			var f = list[i];
			var col = Color.Parse( f.Color ) ?? Color.White;

			// ⚠️ LIFTED OFF THE AUTHORED COLOUR. Soot is a near-black, and a near-black outline on a
			// dark map is not a marker. The tint still says which area is which; the marker has to be
			// legible first.
			var wire = Color.Lerp( col, Color.White, 0.65f );
			var half = f.Size.z * 0.5f;

			if ( f.HasFootprint )
			{
				var fp = f.Footprint;

				for ( int e = 0; e < fp.Count; e++ )
				{
					var a = fp[e];
					var b = fp[( e + 1 ) % fp.Count];

					var a0 = f.Position + f.Rotation * new Vector3( a.x, a.y, -half );
					var a1 = f.Position + f.Rotation * new Vector3( a.x, a.y, half );
					var b0 = f.Position + f.Rotation * new Vector3( b.x, b.y, -half );
					var b1 = f.Position + f.Rotation * new Vector3( b.x, b.y, half );

					d.Line( a0, b0, wire, MarkerLife, global::Transform.Zero, false );
					d.Line( a1, b1, wire, MarkerLife, global::Transform.Zero, false );
					d.Line( a0, a1, wire.WithAlpha( 0.5f ), MarkerLife, global::Transform.Zero, false );
				}
			}
			else
			{
				d.Box( new BBox( -f.Size * 0.5f, f.Size * 0.5f ), wire, MarkerLife,
					new global::Transform( f.Position, f.Rotation ), false );
			}

			d.Text( f.Position + Vector3.Up * ( half + 14f ),
				$"#{i} fog  ·  {f.Color}  ·  density {f.Density:0.##}"
					+ $"\n{f.Size.z:0} tall  ·  feather {f.Feather:0}u"
					+ ( f.Density <= 0.001f ? "  ·  ⚠ DENSITY 0" : "" ),
				size: 13f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	/// <summary>
	/// Room zones — the drawn outline in the tool's pink, and the name over it: a zone is a name for a space, so the name is the
	/// marker. ⚠️ ONE WITH NO NAME SAYS SO — it names nothing until it has one.
	/// </summary>
	void DrawRoomZones( DebugOverlaySystem d )
	{
		var list = ActiveConfig.Current.RoomZones;
		if ( list is null ) return;

		var wire = NZTools.ColorFor( NZTools.RoomZone );

		for ( int i = 0; i < list.Count; i++ )
		{
			var z = list[i];
			if ( z is null ) continue;

			var half = z.Size.z * 0.5f;

			if ( z.HasFootprint )
			{
				var fp = z.Footprint;

				for ( int e = 0; e < fp.Count; e++ )
				{
					var a = fp[e];
					var b = fp[( e + 1 ) % fp.Count];

					var a0 = z.Position + z.Rotation * new Vector3( a.x, a.y, -half );
					var a1 = z.Position + z.Rotation * new Vector3( a.x, a.y, half );
					var b0 = z.Position + z.Rotation * new Vector3( b.x, b.y, -half );
					var b1 = z.Position + z.Rotation * new Vector3( b.x, b.y, half );

					d.Line( a0, b0, wire, MarkerLife, global::Transform.Zero, false );
					d.Line( a1, b1, wire, MarkerLife, global::Transform.Zero, false );
					d.Line( a0, a1, wire.WithAlpha( 0.5f ), MarkerLife, global::Transform.Zero, false );
				}
			}
			else
			{
				d.Box( new BBox( -z.Size * 0.5f, z.Size * 0.5f ), wire, MarkerLife,
					new global::Transform( z.Position, z.Rotation ), false );
			}

			var name = z.Name?.Trim() ?? "";
			d.Text( z.Position + Vector3.Up * ( half + 14f ),
				$"#{i} room zone  ·  {(name.Length > 0 ? name : "⚠ NO NAME")}",
				size: 15f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	/// <summary>`nz_markers_why` — why the editor markers are not on screen.
	///
	/// ⛔ EVERY GATE IN ONE LINE. Invisible markers have at least five unrelated
	/// causes — not creative, preview mode on, no editor component, no debug
	/// overlay, or a draw pass throwing — and from inside the game they all look
	/// identical: an empty room. Guessing between them has cost this project a
	/// session already. This asks the build what is actually true.</summary>
	[ConCmd( "nz_markers_why" )]
	public static void WhyCmd()
	{
		var scene = Game.ActiveScene;
		var ed = scene?.GetAllComponents<MapEditor>().FirstOrDefault();

		Log.Info( "[nz-editor] why markers may be hidden:" );
		Log.Info( $"[nz-editor]   creative       {NZGame.IsCreative}    {(NZGame.IsCreative ? "" : "<-- markers only draw in creative (nz_creative)")}" );
		Log.Info( $"[nz-editor]   preview mode   {NZGame.PreviewMode}    {(NZGame.PreviewMode ? "<-- HIDING THEM (nz_preview 0)" : "")}" );
		Log.Info( $"[nz-editor]   -> visuals on  {NZGame.ShowAuthoringVisuals}" );
		Log.Info( $"[nz-editor]   MapEditor      {(ed.IsValid() ? $"present, enabled={ed.Enabled}" : "MISSING <-- nothing draws at all")}" );
		Log.Info( $"[nz-editor]   DebugOverlay   {(scene?.DebugOverlay is not null ? "ok" : "NULL <-- nothing draws at all")}" );

		if ( _drawFaults.Count == 0 )
		{
			Log.Info( "[nz-editor]   draw faults    none since load" );
			return;
		}

		foreach ( var (pass, since) in _drawFaults )
			Log.Info( $"[nz-editor]   draw fault     '{pass}' threw {(float)since:0.#}s ago <-- THAT pass has no markers" );
	}

	/// <summary>Run one marker pass so a fault in it cannot blank the others.
	///
	/// ⚠️ THROTTLED PER PASS, not per frame. This runs every frame — an unguarded
	/// Log.Warning here would push thousands of identical lines a second and bury
	/// the very message it exists to surface.</summary>
	static readonly Dictionary<string, RealTimeSince> _drawFaults = new();

	static void Safe( string pass, Action draw )
	{
		try
		{
			draw();
		}
		catch ( Exception e )
		{
			if ( _drawFaults.TryGetValue( pass, out var since ) && since < 5f ) return;

			_drawFaults[pass] = 0f;
			Log.Warning( $"[nz-editor] marker pass '{pass}' threw — its markers are missing, the rest still draw: {e.Message}" );
		}
	}

	/// <summary>
	/// Cyan arrows for the nav links.
	///
	/// ⚠️ DRAWN AS AN ARROW, not a line. Direction is the single most important
	/// thing about a link and the one a line cannot show — a one-way drop and a
	/// one-way climb look identical otherwise, and getting them backwards means
	/// zombies walking up a sheer wall.
	/// </summary>
	void DrawNavLinks( DebugOverlaySystem d )
	{
		var col = NZTools.ColorFor( NZTools.NavLink );

		foreach ( var l in ActiveConfig.Current.NavLinks )
		{
			d.Sphere( new Sphere( l.A, 8f ), col, MarkerLife, global::Transform.Zero, false );
			d.Sphere( new Sphere( l.B, 8f ), col, MarkerLife, global::Transform.Zero, false );
			d.Line( l.A, l.B, col, MarkerLife, global::Transform.Zero, false );

			// The head: two short strokes back from B, so the arrow reads from
			// any angle rather than only side-on.
			var dir = (l.B - l.A).Normal;
			var back = l.B - dir * 20f;
			var side = dir.Cross( Vector3.Up ).Normal * 8f;
			if ( side.Length < 0.1f ) side = Vector3.Forward * 8f;

			d.Line( l.B, back + side, col, MarkerLife, global::Transform.Zero, false );
			d.Line( l.B, back - side, col, MarkerLife, global::Transform.Zero, false );

			if ( l.BiDirectional )
			{
				var fwd = l.A + dir * 20f;
				d.Line( l.A, fwd + side, col, MarkerLife, global::Transform.Zero, false );
				d.Line( l.A, fwd - side, col, MarkerLife, global::Transform.Zero, false );
			}

			d.Text( (l.A + l.B) * 0.5f + Vector3.Up * 10f,
				(l.BiDirectional ? "two-way" : "one-way")
					+ (l.Fall > 8f ? $" · drop {l.Fall:0}u" : ""),
				size: 13f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	/// <summary>
	/// Mark each power switch, and say what the map's power is doing.
	///
	/// The block itself is already visible in the world, so this adds the thing
	/// the block cannot show: whether the power is on, and that removing the
	/// last switch would turn it on permanently.
	/// </summary>
	/// <summary>
	/// Mark each teleporter's DESTINATION and the trip it makes.
	///
	/// ⚠️ The pad is already a solid box in the world, so this draws the half the pad cannot show:
	/// where it goes. Without the arrow a destination is an invisible point somewhere else on the
	/// map, and the only way to find out which pad owns it is to stand on one.
	/// </summary>
	void DrawTeleporters( DebugOverlaySystem d )
	{
		var col = NZTools.ColorFor( NZTools.Teleporter );
		var list = ActiveConfig.Current.Teleporters;

		for ( int i = 0; i < list.Count; i++ )
		{
			var t = list[i];

			d.Sphere( new Sphere( t.B, 12f ), col, MarkerLife, global::Transform.Zero, false );
			d.Line( t.A, t.B, col, MarkerLife, global::Transform.Zero, false );

			// The head: two short strokes back from B, so the arrow reads from any angle rather
			// than only side-on. Same shape as DrawNavLinks, deliberately.
			var dir = (t.B - t.A).Normal;
			var back = t.B - dir * 20f;
			var side = dir.Cross( Vector3.Up ).Normal * 8f;
			if ( side.Length < 0.1f ) side = Vector3.Forward * 8f;

			d.Line( t.B, back + side, col, MarkerLife, global::Transform.Zero, false );
			d.Line( t.B, back - side, col, MarkerLife, global::Transform.Zero, false );

			d.Text( t.A + Vector3.Up * 24f,
				$"#{i} teleporter"
					+ (t.Price > 0 ? $" · {t.Price:N0}" : " · free")
					+ (t.Cooldown > 0f ? $" · {t.Cooldown:0}s" : "")
					+ (t.RequiresPower ? " · power" : "")
					+ $" · flag {DoorLinks.Display( t.Link )}",
				size: 13f, flags: TextFlag.Center, duration: MarkerLife );

			d.Text( t.B + Vector3.Up * 24f, $"#{i} exit", size: 13f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	/// <summary>
	/// Springboards — the circle that throws, and an arrow up to the height it throws to.
	///
	/// ⛔ THIS IS THE ONLY WAY TO SEE ONE. A springboard draws nothing in a round, by request, so without the marker a placed
	/// pad could be found only by walking onto it.
	/// </summary>
	void DrawSpringboards( DebugOverlaySystem d )
	{
		var list = ActiveConfig.Current.Springboards;
		if ( list is null ) return;

		var col = NZTools.ColorFor( NZTools.Springboard );
		const int segments = 24;

		for ( int i = 0; i < list.Count; i++ )
		{
			var s = list[i];
			var c = s.Position + Vector3.Up * 2f;   // a hair above the floor, so the ring is not lost in it

			for ( int k = 0; k < segments; k++ )
			{
				var a0 = MathF.PI * 2f * k / segments;
				var a1 = MathF.PI * 2f * (k + 1) / segments;

				d.Line( c + new Vector3( MathF.Cos( a0 ), MathF.Sin( a0 ), 0f ) * s.Radius,
					c + new Vector3( MathF.Cos( a1 ), MathF.Sin( a1 ), 0f ) * s.Radius,
					col, MarkerLife, global::Transform.Zero, false );
			}

			// The throw: a line up to the height it reaches, with a head on it that reads from any side.
			var up = SpringboardSystem.ApexHeight( s.Strength, Scene );
			var top = c + Vector3.Up * up;

			d.Line( c, top, col, MarkerLife, global::Transform.Zero, false );
			d.Line( top, top + new Vector3( 10f, 0f, -14f ), col, MarkerLife, global::Transform.Zero, false );
			d.Line( top, top + new Vector3( -10f, 0f, -14f ), col, MarkerLife, global::Transform.Zero, false );
			d.Line( top, top + new Vector3( 0f, 10f, -14f ), col, MarkerLife, global::Transform.Zero, false );
			d.Line( top, top + new Vector3( 0f, -10f, -14f ), col, MarkerLife, global::Transform.Zero, false );

			d.Text( c + Vector3.Up * 24f,
				$"#{i} springboard · {s.Strength:0} u/s, about {up:0} up · radius {s.Radius:0}"
					+ (s.SafeLanding ? "" : " · ⚠ fall damage"),
				size: 13f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	/// <summary>
	/// Mark each soul box, its RANGE, and how far its flag has got.
	///
	/// ⛔ THE RANGE CIRCLE IS THE WHOLE POINT OF THIS OVERLAY. 500 units is over 40 feet and the box
	/// itself is 48 — so the area that actually collects souls is invisible, ten times the size of
	/// the thing you placed, and the only way to find out whether two boxes overlap is to draw it.
	/// </summary>
	void DrawSoulBoxes( DebugOverlaySystem d )
	{
		var col = NZTools.ColorFor( NZTools.SoulBox );
		var list = ActiveConfig.Current.SoulBoxes;

		for ( int i = 0; i < list.Count; i++ )
		{
			var b = list[i];

			// ⚠️ A SPHERE, NOT A FLAT RING. The kill test is a plain distance so it reaches upward
			// and downward too, and a floor circle would claim a box ignores the walkway above it.
			d.Sphere( new Sphere( b.Position, b.Range ), col.WithAlpha( 0.25f ), MarkerLife,
				global::Transform.Zero, false );

			var live = SoulBoxManager.All().FirstOrDefault( x => x.Index == i );
			var fill = live is null ? "" : $"  ·  {live.Current}/{live.Target}";

			var group = list.Count( x => DoorLinks.Same( x.Link, b.Link ) );

			d.Text( b.Position + Vector3.Up * (b.Height + 16f),
				$"#{i} soul box  ·  {b.Target} kills / {b.Range:0}u{fill}"
					+ (b.RequiresPower ? "  ·  power" : "")
					+ (b.Powerup ? "  ·  powerup" : "")
					+ $"\nflag {DoorLinks.Display( b.Link )}"
					+ (DoorLinks.IsUnlinked( b.Link )
						? "  (gates nothing)"
						: $"  ·  ALL {group} must fill"),
				size: 13f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	void DrawSwitches( DebugOverlaySystem d )
	{
		var list = ActiveConfig.Current.PowerSwitches;
		var col = NZTools.ColorFor( NZTools.PowerSwitch );

		// ⚠️ THE SIZE THE SWITCHES ACTUALLY ARE, from the manager, falling back to
		// the config's placeholder when nothing is standing. Drawing the config's
		// 24x24x48 over a prop that is nothing like it would misreport what you
		// placed — the one thing an authoring marker must not do.
		var live = PowerManager.Instance?.SwitchSize ?? default;

		for ( int i = 0; i < list.Count; i++ )
		{
			var s = list[i];
			var size = live.IsNearZeroLength ? s.Size : live;

			// ⚠️ ROTATED WITH THE SWITCH, not axis-aligned. Now that a switch can
			// be mounted flat on any surface, an upright world-space box would
			// report a placement the prop does not have — and the marker is how
			// you check the alignment landed, so it has to be the alignment.
			d.Box( new BBox( -size * 0.5f, size * 0.5f ), col, MarkerLife,
				new global::Transform( s.Position, s.Rotation ), false );

			d.Text( s.Position + Vector3.Up * (size.z * 0.5f + 12f),
				$"#{i}  power {(Power.IsOn ? "ON" : "OFF")}",
				color: col, size: 13f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	/// <summary>
	/// Label each zombie spawn with its link and whether it is live right now.
	///
	/// Without this a map of identical green boxes says nothing about which door
	/// owns which spawn — and getting that wrong is invisible until a wave
	/// stalls or the horde comes from a room you have not opened.
	///
	/// Greyed when gated, so the set that is actually feeding the current wave
	/// reads at a glance.
	/// </summary>
	void DrawSpawnLinks( DebugOverlaySystem d )
	{
		var list = ActiveConfig.Current.ZombieSpawns;
		if ( list.Count == 0 ) return;

		int round = RoundManager.Instance?.Round ?? 1;
		var live = NZTools.ColorFor( NZTools.ZombieSpawn );
		var gated = new Color( 0.55f, 0.55f, 0.55f, 1f );

		for ( int i = 0; i < list.Count; i++ )
		{
			var s = list[i];
			var why = s.Blocker( round, Power.IsOn );
			var ok = string.IsNullOrEmpty( why );

			var label = DoorLinks.IsUnlinked( s.Link )
				? $"#{i}  open"
				: $"#{i}  flag {s.Link}";

			if ( !ok ) label += $"\n{why}";

			d.Text( s.Position + Vector3.Up * 84f, label,
				color: ok ? live : gated, size: 13f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	/// <summary>
	/// Barrier markers — amber, and WIDER THAN TALL, unlike the person-sized
	/// spawn boxes. A barrier fills a gap rather than marking where someone
	/// stands, so a different silhouette says which kind of thing it is before
	/// the colour does.
	///
	/// Already-bought barriers are skipped: they are gone from the world, and
	/// leaving a marker floating where one used to be reads as a bug.
	/// </summary>
	void DrawDebris( DebugOverlaySystem d )
	{
		var col = NZTools.ColorFor( NZTools.Debris );
		var fill = col.WithAlpha( 0.22f );

		var cube = Model.Cube.Bounds.Size;

		var list = ActiveConfig.Current.Debris;

		var mgr = DebrisManager.Instance;

		for ( int i = 0; i < list.Count; i++ )
		{
			var item = list[i];

			// ⛔ MARKED IF IT IS STANDING, NOT IF ITS LINK IS SHUT. This used to
			// skip anything `DoorLinks.IsOpen`, which is runtime game state — so
			// after a round in which you bought a door, coming back to creative
			// left the barrier unmarked. It is worse than it sounds now that
			// creative builds every barrier regardless: the wall is in front of
			// you and has no price, no flag and no outline.
			//
			// A bought barrier is destroyed, so "is it standing" still hides the
			// marker of one you buy mid-session — which is what the link check was
			// really for.
			if ( mgr is not null )
			{
				if ( !mgr.IsStanding( i ) ) continue;
			}
			else if ( DoorLinks.IsOpen( item.Link ) ) continue;

			// A block outlines its ACTUAL dimensions; a prop gets a nominal box.
			// Drawing a fixed size over a built block would misreport what you
			// made, which is the one thing an authoring marker must not do.
			var size = item.IsBlock ? item.Size : new Vector3( 52f, 52f, 56f );
			var centre = item.IsBlock
				? item.Position
				: item.Position + Vector3.Up * (size.z * 0.5f);

			// ⚠️ A SHAPED BARRIER GETS ITS OWN OUTLINE, not its bounding box. The
			// marker exists to report what you built — and for an L-shaped barrier
			// the enclosing box is exactly the wrong answer, since covering the
			// dent is the thing the author is checking for.
			if ( item.HasFootprint )
			{
				// ⛔ FILLED, not just wireframed. The amber faces are what make a
				// marker read as a SOLID BARRIER rather than a floating outline —
				// I shipped shaped barriers as bare lines and they came straight
				// back as "it should have the collision and nav block on", because
				// a wireframe is exactly what an inert marker looks like.
				//
				// The fill is the real built model, borrowed from the manager, so
				// the faces you see are the faces the player walks into.
				var shape = mgr?.ShapeOf( i );

				if ( shape is not null )
				{
					// Same transform the renderer uses: the prism runs from z=0 up,
					// the barrier's origin is its middle.
					var at = item.Position + item.Rotation * new Vector3( 0f, 0f, -size.z * 0.5f );
					d.Model( shape, fill, MarkerLife, new global::Transform( at, item.Rotation ),
						false, false );
				}

				DrawFootprint( d, item, size.z, col );
			}
			else
			{
				var scale = new Vector3( size.x / cube.x, size.y / cube.y, size.z / cube.z );
				var xf = new global::Transform( centre, item.Rotation ).WithScale( scale );

				d.Model( Model.Cube, fill, MarkerLife, xf, false, false );

				// Rotated outline, so a block placed at an angle reads as angled.
				d.Box( new BBox( -size * 0.5f, size * 0.5f ), col, MarkerLife,
					new global::Transform( centre, item.Rotation ), false );
			}

			// The price and flag ARE the barrier's identity while building —
			// two amber boxes are indistinguishable without them.
			//
			// The power state belongs here too: "0 points" reads as a free door
			// until you know it needs power, at which point it is a shutter that
			// opens itself. Those are different objects and they looked alike.
			var label = $"flag {DoorLinks.Display( item.Link )}\n{item.Price}";

			if ( item.RequiresPower )
				label += item.Price == 0 ? "  ⚡ opens on power" : "  ⚡ needs power";

			d.Text( centre + Vector3.Up * (size.z * 0.5f + 12f),
				label, size: 14f, flags: TextFlag.Center, duration: MarkerLife );
		}

	}

	/// <summary>
	/// Violet outlines for the invisible walls.
	///
	/// ⛔ THE ONLY WAY TO SEE ONE. A debris marker decorates a wall you can
	/// already see; this marker IS the wall as far as the author is concerned —
	/// without it an invisible wall cannot be found, judged or removed, and a
	/// map ends up with duplicates stacked in the same doorway because there was
	/// no way to tell one had already been placed.
	///
	/// ⚠️ Drawn for the visible ones too, so the two kinds sit in one list and
	/// the outline means "a wall is here" rather than "a wall you cannot see is
	/// here". The label says which.
	/// </summary>
	void DrawInvisibleWalls( DebugOverlaySystem d )
	{
		var list = ActiveConfig.Current.InvisibleWalls;
		if ( list.Count == 0 ) return;

		var col = NZTools.ColorFor( NZTools.InvisibleWall );
		var fill = col.WithAlpha( 0.22f );
		var cube = Model.Cube.Bounds.Size;
		var mgr = InvisibleWallManager.Instance;

		for ( int i = 0; i < list.Count; i++ )
		{
			var w = list[i];
			var size = w.Size;

			if ( w.HasFootprint )
			{
				var shape = mgr?.ShapeOf( i );

				if ( shape is not null )
				{
					var at = w.Position + w.Rotation * new Vector3( 0f, 0f, -size.z * 0.5f );
					d.Model( shape, fill, MarkerLife, new global::Transform( at, w.Rotation ),
						false, false );
				}

				DrawWallOutline( d, w, size.z, col );
			}
			else
			{
				var scale = new Vector3( size.x / cube.x, size.y / cube.y, size.z / cube.z );
				d.Model( Model.Cube, fill, MarkerLife,
					new global::Transform( w.Position, w.Rotation ).WithScale( scale ),
					false, false );

				d.Box( new BBox( -size * 0.5f, size * 0.5f ), col, MarkerLife,
					new global::Transform( w.Position, w.Rotation ), false );
			}

			// ⚠️ Says INVISIBLE or VISIBLE outright. Two violet outlines are
			// otherwise identical, and which one the player will actually see is
			// the only setting this tool has.
			d.Text( w.Position + Vector3.Up * (size.z * 0.5f + 12f),
				w.Visible ? "wall · visible" : "wall · invisible",
				size: 13f, flags: TextFlag.Center, duration: MarkerLife );
		}
	}

	/// <summary>The drawn outline of a shaped wall. Same wireframe as a shaped
	/// barrier — one shape language for anything built from corners.</summary>
	static void DrawWallOutline( DebugOverlaySystem d, InvisibleWall w, float height, Color col )
	{
		var half = height * 0.5f;
		var fp = w.Footprint;

		for ( int i = 0; i < fp.Count; i++ )
		{
			var a = fp[i];
			var b = fp[(i + 1) % fp.Count];

			var a0 = w.Position + w.Rotation * new Vector3( a.x, a.y, -half );
			var a1 = w.Position + w.Rotation * new Vector3( a.x, a.y, half );
			var b0 = w.Position + w.Rotation * new Vector3( b.x, b.y, -half );
			var b1 = w.Position + w.Rotation * new Vector3( b.x, b.y, half );

			d.Line( a0, b0, col, MarkerLife, global::Transform.Zero, false );
			d.Line( a1, b1, col, MarkerLife, global::Transform.Zero, false );
			d.Line( a0, a1, col, MarkerLife, global::Transform.Zero, false );
		}
	}

	/// <summary>
	/// Wireframe the drawn outline of a shaped barrier — top ring, bottom ring
	/// and a post at each corner.
	///
	/// Deliberately the same look as DrawPendingCorners, so what you clicked and
	/// what got built are directly comparable instead of being two different
	/// kinds of marker you have to translate between.
	/// </summary>
	static void DrawFootprint( DebugOverlaySystem d, Debris item, float height, Color col )
	{
		var half = height * 0.5f;
		var fp = item.Footprint;

		for ( int i = 0; i < fp.Count; i++ )
		{
			var a = fp[i];
			var b = fp[(i + 1) % fp.Count];

			// Local XY is measured in the barrier's own yaw, so rotate it back out
			// before offsetting from Position — the same transform the mesh uses.
			var a0 = item.Position + item.Rotation * new Vector3( a.x, a.y, -half );
			var a1 = item.Position + item.Rotation * new Vector3( a.x, a.y, half );
			var b0 = item.Position + item.Rotation * new Vector3( b.x, b.y, -half );
			var b1 = item.Position + item.Rotation * new Vector3( b.x, b.y, half );

			d.Line( a0, b0, col, MarkerLife, global::Transform.Zero, false );
			d.Line( a1, b1, col, MarkerLife, global::Transform.Zero, false );
			d.Line( a0, a1, col, MarkerLife, global::Transform.Zero, false );
		}
	}

	/// <summary>
	/// The footprint being clicked out, before it becomes a block.
	///
	/// Without this you are placing invisible points and only find out the
	/// shape was wrong after it has been built.
	/// </summary>
	void DrawPendingCorners( DebugOverlaySystem d )
	{
		if ( _corners.Count == 0 ) return;

		var col = NZTools.ColorFor( NZTools.Debris );

		for ( int i = 0; i < _corners.Count; i++ )
		{
			d.Sphere( new Sphere( _corners[i], 6f ), col, 0f, global::Transform.Zero, false );

			if ( i > 0 )
				d.Line( _corners[i - 1], _corners[i], col, 0f, global::Transform.Zero, false );
		}

		// Close the loop once there are enough points to imply a shape, and show
		// how tall it will end up.
		if ( _corners.Count >= 3 )
			d.Line( _corners[^1], _corners[0], col, 0f, global::Transform.Zero, false );

		if ( !_awaitingHeight )
		{
			d.Text( _corners[0] + Vector3.Up * 24f,
				$"{_corners.Count}/{DebrisCorners} corners",
				size: 14f, flags: TextFlag.Center );
			return;
		}

		// ⚠️ TRACED PER FRAME, and only here. The height is now clicked, so the
		// author needs to see what they are about to get BEFORE committing —
		// "click at the height you want" is not much of an instruction without a
		// preview of where that lands. Confined to creative, with the debris tool
		// armed, between the last corner and the height click.
		var aim = AimTrace( quiet: true );
		float groundZ = float.MaxValue;
		foreach ( var c in _corners )
			groundZ = MathF.Min( groundZ, c.z );

		float height = aim is null ? 0f : aim.Value.HitPosition.z - groundZ;

		if ( height > 1f )
		{
			for ( int i = 0; i < _corners.Count; i++ )
			{
				var a = _corners[i].WithZ( groundZ );
				var b = _corners[(i + 1) % _corners.Count].WithZ( groundZ );

				d.Line( a, a + Vector3.Up * height, col, 0f, global::Transform.Zero, false );
				d.Line( a + Vector3.Up * height, b + Vector3.Up * height, col, 0f,
					global::Transform.Zero, false );
			}
		}

		d.Text( _corners[0] + Vector3.Up * 24f,
			height > 1f
				? $"{_corners.Count} corners\nclick to set height {height:0}"
				: $"{_corners.Count} corners\naim higher — click sets the height",
			size: 14f, flags: TextFlag.Center );
	}

	/// <summary>
	/// ⚠️ DEPTH TESTED — the trailing `overlay` argument is false.
	///
	/// Left on, markers draw through walls and floors, and a map with a few
	/// dozen of them becomes unreadable: you cannot tell which room a spawn is
	/// actually in, and markers two floors down clutter the one you are
	/// standing in. Occluding them is what makes the overlay useful for
	/// judging a room rather than just confirming a marker exists.
	///
	/// Each box is drawn twice, a hair apart, to thicken the wireframe — the
	/// debug overlay has no line width and a single pass reads as faint.
	/// </summary>
	static void DrawSpawns( DebugOverlaySystem d,
		System.Collections.Generic.List<SpawnPoint> list, string tool )
	{
		var col = NZTools.ColorFor( tool );

		// Translucent fill + solid wireframe. The fill gives the marker volume
		// so it reads as a body-sized space rather than a floating outline; the
		// wire keeps its edges crisp where the fill washes out against a bright
		// floor. Model.Cube is a built-in unit cube, scaled by the transform.
		var fill = col.WithAlpha( 0.25f );

		foreach ( var s in list )
		{
			var min = s.Position + new Vector3( -14, -14, 0 );
			var max = s.Position + new Vector3( 14, 14, 72 );
			var centre = (min + max) * 0.5f;

			// ⚠️ Scale from the cube's OWN bounds, not a guessed unit size. I
			// assumed Model.Cube was 100 units and divided by that; it is not,
			// so the fill came out smaller than its outline. Deriving it means
			// the fill matches the wireframe whatever the model measures.
			var cube = Model.Cube.Bounds.Size;
			var want = new Vector3( 28f, 28f, 72f );
			var scale = new Vector3(
				cube.x > 0f ? want.x / cube.x : 1f,
				cube.y > 0f ? want.y / cube.y : 1f,
				cube.z > 0f ? want.z / cube.z : 1f );

			d.Model( Model.Cube, fill, MarkerLife,
				new global::Transform( centre, Rotation.Identity ).WithScale( scale ),
				false, false );

			d.Box( new BBox( min, max ), col, MarkerLife, global::Transform.Zero, false );
			DrawFacing( d, s, col );
		}
	}

	/// <summary>A line out of the front, so facing reads at a glance. Position
	/// alone does not tell you which way someone will be looking, and that is
	/// half the reason the marker exists.</summary>
	static void DrawFacing( DebugOverlaySystem d, SpawnPoint s, Color color )
	{
		var from = s.Position + Vector3.Up * 36f;
		var to = from + s.Rotation.Forward * 40f;

		d.Line( from, to, color, MarkerLife, global::Transform.Zero, false );
		d.Line( from + Vector3.Up * 0.6f, to + Vector3.Up * 0.6f,
			color, MarkerLife, global::Transform.Zero, false );
	}
}