OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / map_dc_level.gd

9052 lines · gd ref:
extends "res://scenes/maps/map_core.gd"
## DC Level test world — the EXACT brush level (DCLevel), NOT the octree clipmap.
##
## DCLevel owns a brush store and derives everything from it:
##
##     BrushStore --csg4--> visible skin --dc_level_mesh--> ArrayMesh
##                \--unclipped hulls-------------------> convex colliders
##
## This is step 3 of level-mesh-implementation.md §12 — "a standalone Godot scene
## rendering START as exact mesh". The gate is that doc's own acceptance criteria:
##
##   triangles        12,747   (§6.2, csg4-clipped skin; RAW hulls would be 13,718)
##   faces             6,487
##   surface area     32,101 m²
##   colliders         1,164   (§10 — 1156 brushes contribute visible FACES but all
##                              1164 need volume; build collision from the render
##                              mesh and you fall through eight of them)
##   max normal dev    0.034°  (sharp edges, as a number rather than a claim)
##
## Every one of those came from an offline oracle (tests/test_csg4_polycount.cpp,
## test_level_mesh.cpp). This scene is the first time they meet a renderer, so a
## divergence here is a bug in the ENGINE port and the oracle is the reference.
##
## STANDALONE ARENA: there is deliberately no DCTerrain in this scene. §5a's escape
## hatch — with no procedural world there is no level<->world seam to solve, which
## is the one genuinely undesigned piece of the architecture ("outside walls" needs
## a real definition, and it is the crux).
##
## EPHEMERAL import (§9a.1): brushes are re-derived from the .MAP on every load and
## never written to StateBase. The durable path — editor "Load .MAP" writing brushes
## into room state — is a later step and is blocked on `world:level:` being added to
## BOTH MAP_SCOPED_PREFIXES and AUTHOR_SCOPED_PREFIXES in game_state.gd.
##
## KNOWN GAP (§10): DCLevel's colliders are raw PhysicsServer3D shapes on one static
## body with no body_attach_object_instance_id, so a raycast hit has no owner and the
## editor cannot resolve a brush_id from a pick. Must be fixed before the editor.

## Dev-only absolute paths, matching map_dc_terrain.gd's KEY_M import. Bundle into
## res:// before this ships anywhere but a dev box.
const MAP_PATH := "/storage/projects/gamedev/quake/quakemash/START.MAP"

## Per-scene override so a second scene can reuse this whole script with a
## different .MAP (e.g. a generated level). Empty = use MAP_PATH.
@export var map_path_override: String = ""

## Keep the .MAP's own light entities instead of replacing them with
## RTLIGHTS_PATH. Required for any map that is not START.
@export var use_map_lights: bool = false

## Colour every face by its MATERIAL ID alone -- no textures, no atlas. Emit a .MAP
## whose faces carry one material name per room and each room reads as one colour,
## which is how a decomposition gets judged in-engine rather than from a chart.
@export var flat_room_colors: bool = false

## Import a .MAP at all. OFF gives an EMPTY DCLevel with the whole editor, the
## journal and the admin surface still live, so brushes are built from scratch
## and persist like imported ones -- what map_mountain wants: brushes and terrain
## CSG on a world that has no Quake level. OFF also skips everything that only
## exists to present START.MAP: the .MAP fetch/serve over the mesh, WAD/PBR
## texturing, sky lights, rtlights, entities and the forced daylight.
## Default ON: every map that inherits this behaves exactly as before.
@export var level_import: bool = true


## The .MAP this scene should load. Everything that resolved MAP_PATH directly
## goes through here so the override reaches the overlay announce/request paths
## too — otherwise a peer would be served START.MAP for a generated level.
func _map_path() -> String:
	return map_path_override if map_path_override != "" else MAP_PATH
const WAD_PATH := "/storage/projects/gamedev/quake/quakemash/gfx/base.wad"
const PAL_PATH := "/storage/projects/gamedev/quake/darkyplafe/id1/gfx/palette.lmp"
## hemebond/quake-textures build output — `<name>_gloss.jpg` / `_glow.jpg` / `_bump.jpg`
## per WAD texture. Same directory map_dc_terrain.gd:359 already loads its PBR from.
const TEX_BUILD_DIR := "/storage/projects/gamedev/quake/quake-textures/build_out"
## Hand-curated DarkPlaces realtime lights. These REPLACE the raw .MAP light
## entities: the importer's light* spawner is a crude fallback (range =
## brightness x world_scale, energy = clamp(brightness/200), shadows always off),
## whereas RtlightManager was canonicalized against DarkPlaces r_shadow.c —
## authored radius, per-light energy = ambient+diffuse+specular, shadow flags, and
## EMA importance selection. Without this the level is lit by the guess.
const RTLIGHTS_PATH := "/storage/projects/gamedev/quake/quake-rtlights/id1/maps/start.rtlights"

## 0.028 m/unit: Quake's 56-unit player becomes 1.58 m, matching our capsule. Same
## value map_dc_terrain uses, so geometry lands at identical scale and the two are
## directly comparable.
const WORLD_SCALE := 0.028

## Daylight. The inherited game.gd Sky3DExtended sits at 19.6 (night, sun energy 0) —
## fine for map_test, useless here: this world exists to judge sharp edges, crease
## geometry and material seams, and none of that is visible in the dark.
const DAY_TIME := 12.0

## Sky AreaLight3D tuning. INSET pushes the light off the brush face into the room
## so the solid brush it belongs to cannot occlude it. ENERGY is explicit because
## area_normalize_energy is off (see _spawn_sky_area_lights).
const SKY_LIGHT_INSET := 0.05
const SKY_LIGHT_ENERGY := 4.0
## How hard sky faces glow. The sky is a light SOURCE in the fiction — stained-glass
## behaviour — so the surface itself emits rather than merely being lit.
const SKY_EMISSION := 2.0

@onready var level: Node3D = get_node_or_null("DCLevel")
@onready var placeholder_floor: Node3D = get_node_or_null("PlaceholderFloor")

var _prev_time: float = -1.0
var _tod: Node = null


func _ready() -> void:
	super._ready()
	# THE BUILDER CONTEXT IS ACTIVE WHILE THIS MAP IS. Godot's InputMap has no notion
	# of context, so without this the editor bindings would be live in every map —
	# including ones where the same keys mean something else. Popped in _exit_tree.
	# HELD AS self: on a map change the OLD map's pop runs after this push, and an
	# unowned pop took `builder` away from the new map (gate_map_change_keys).
	Keymap.push_context("builder", self)
	# ON IN BUILD MODE, off otherwise: the wireframe is an editing aid, and drawing
	# every brush edge in the level while you are playing would be noise.
	GameMode.mode_changed.connect(func(m: String) -> void:
		_ensure_brush_view().set_shown(m == GameMode.BUILD)
		_ensure_entity_links()
		_apply_overlays()                     # links follow the mode (and their overlay)
		_show_highlights(m == GameMode.BUILD))
	_apply_overlays()                         # the saved overlay states (picking flags)
	# Noon exists to judge the Quake level's edges; a map with no level keeps its sky.
	if level_import:
		_force_daylight()
	# The editor bars (redesign E1). Deferred: BuildDock must have built its regions.
	call_deferred("_register_editor_bars")

	if level == null:
		push_warning("[MapDCLevel] No DCLevel node — the loaded libdc_terrain build does not register the class. Placeholder floor only.")
		nav_rebuild.call_deferred()   # bots still route over the scene's own colliders
		return

	# Defer: DCLevel::_ready creates its MeshInstance3D child, and _upload_collision
	# needs get_world_3d(), so the node must be in the tree before we feed it.
	call_deferred("_import_level")
	# BOT NAVIGATION for every level map (see "navigation" below). Queued AFTER the
	# import so the deferred calls run in that order and the brushes are in the store
	# when the graph is built; later edits rebuild it on their own.
	nav_rebuild.call_deferred()


func _exit_tree() -> void:
	Keymap.pop_context("builder", self)
	if _face_sel_on:
		Keymap.pop_context("face_sel", self)
		_face_sel_on = false
	# The strip belongs to THIS map's pack material; the next map may have none.
	if _tex_strip != null:
		var dock := get_node_or_null("/root/BuildDock")
		if dock != null:
			dock.unregister("textures")
			dock.unregister("face")
		_tex_strip = null
		_face_panel = null
	# The asset browser drives THIS map's libraries (#120 L4).
	if _asset_browser != null:
		var dock3 := get_node_or_null("/root/BuildDock")
		if dock3 != null:
			dock3.unregister("assets")
		_asset_browser = null
	# The bars drive THIS map; the next map may not be a brush editor at all.
	var dock2 := get_node_or_null("/root/BuildDock")
	if dock2 != null:
		dock2.unregister("topbar")
		dock2.unregister("tools")
		dock2.unregister("object")
		dock2.unregister("bots")
	_object_panel = null
	_bot_panel = null
	var hud := get_node_or_null("/root/BuildHud")
	if hud != null:
		hud.info_provider = Callable()
	_top_bar = null
	_tool_bar = null
	# Leave the shared sky as we found it — game.gd's Sky3DExtended outlives this map
	# and map_test expects its own time of day.
	if _tod != null and _prev_time >= 0.0:
		_tod.current_time = _prev_time


## Nudge the INHERITED sky rather than adding a WorldEnvironment. A second
## WorldEnvironment would trip game.gd's _remove_map_world_environment and detach
## game.gd's Sky3D entirely, making this map own sun/moon wiring and diverge from
## map_test. Borrowing the time of day is the smaller change.
func _force_daylight() -> void:
	var game := get_tree().root.get_node_or_null("Game")
	if game == null:
		return
	var sky := game.get_node_or_null("Sky3DExtended")
	if sky == null:
		return
	_tod = sky.get_node_or_null("TimeOfDay")
	if _tod == null or not ("current_time" in _tod):
		return
	# SAVED STATE WINS; THIS IS THE DEFAULT FOR A ROOM THAT HAS NONE.
	#
	# Without the check this fought the atmosphere panel and the winner was decided
	# by arrival order rather than by intent: on a cold join the room's saved sky
	# lands AFTER the map (room state is still loading at _ready), so saved won —
	# but on a later map change within the same room the value is already in
	# GameState's mirror, no state_changed is re-emitted, and this overwrote it. So
	# the same setting behaved differently depending on how you got here.
	#
	# A room with a saved time is a world someone authored. Forcing noon over it
	# makes the panel's time slider pointless on every map that inherits this one.
	# Through SkyDefaults now, so the rule this comment describes has ONE
	# implementation — map_dc_terrain had none at all and was clobbering saved
	# state on every entry (hyper-games #42 P1).
	var keys: Array[String] = []
	if GameState:
		keys = GameState.keys_with_prefix(SkyDefaults.PREFIX)
	if SkyDefaults.unsaved(["current_time"] as Array[String], keys).is_empty():
		print("[MapDCLevel] daylight SKIPPED — the room has a saved sky time (%.1f)"
				% float(GameState.get_state("world:sky:current_time")))
		return
	_prev_time = _tod.current_time
	_tod.current_time = DAY_TIME
	# Sky3D bug (game.gd documents it): current_time's setter does not call
	# _update_celestial_coords, and with game_time_enabled=false _process never
	# does either — so the sun does not actually move without this. game.gd hooks
	# time_changed for the same reason.
	if _tod.has_method("_update_celestial_coords"):
		_tod._update_celestial_coords()
	print("[MapDCLevel] daylight — TimeOfDay %.1f -> %.1f" % [_prev_time, DAY_TIME])


## Resolve the .MAP, then import it. Two ways in, and the second is why the
## overlay exists: this scene's paths are dev-box absolutes (see the const block),
## so a peer that simply does not have START.MAP had no level at all and no way to
## get one. Now it asks the mesh for the hash another peer announced.
func _import_level() -> void:
	# EAGERLY, not on first keypress. _ensure_persistence was previously reached
	# only from the K carve keybind, so on a headless peer — which presses nothing —
	# _persist stayed null and both branches below silently did nothing. It is also
	# what subscribes to GameState.state_changed, so creating it lazily meant a peer
	# did not replay ANOTHER peer's carves until it happened to carve something
	# itself. Pre-existing, and this is the fix for both.
	_ensure_persistence()
	_bake_scene_boxes()
	if not level_import:
		_adopt_scene_objects()          # before _start_empty_level's first fold
		_start_empty_level()
		return
	if FileAccess.file_exists(_map_path()):
		_import_from(_map_path())
		_announce_source(_map_path())
		return
	_request_source()


## The level the map's SCENE BOXES are baked into. Base keys are "i:<level_id>:<n>" by
## insertion order, so they get a level of their own: a .MAP import (level 0) that grew
## a brush would otherwise shift every scene box's key and break edits aimed at them.
const SCENE_LEVEL_ID := 1


## SCENE BOXES BECOME BASE BRUSHES (2026-10-05: "the editor cannot select the created
## elements in the tscn starter map"). A StaticBody3D child whose collision is exactly
## one BoxShape3D was invisible to the editor — no journal identity, so a click logged
## "not level geometry" and nothing could select, move or delete it. Each becomes a
## BASE brush (key "", level SCENE_LEVEL_ID): the fold's rewind keeps it live, and
## brush.remove / translate / ... records target its key like any brush. Rotation and
## scale come from the shape's transform relative to the level.
##
## NODE-NAME ORDER, so the keys do not move when the .tscn is reordered (renaming a box
## does move them). Children that are not the box's collision or mesh (NorthHub's
## label) move up to the map, keeping their place. Before the first fold, which is
## what makes the keys identical on every peer. Returns how many were baked.
func _bake_scene_boxes() -> int:
	if level == null:
		return 0
	var boxes: Array = []
	for c in get_children():
		if not (c is StaticBody3D):
			continue
		var shapes: Array = c.get_children().filter(func(n: Node) -> bool: return n is CollisionShape3D)
		if shapes.size() != 1:
			continue
		var cs := shapes[0] as CollisionShape3D
		if cs.disabled or not (cs.shape is BoxShape3D):
			continue
		boxes.append([String(c.name), c, cs])
	boxes.sort_custom(func(a: Array, b: Array) -> bool: return String(a[0]) < String(b[0]))
	var to_level: Transform3D = (level as Node3D).global_transform.affine_inverse()
	var n := 0
	for e: Array in boxes:
		var body := e[1] as StaticBody3D
		var cs := e[2] as CollisionShape3D
		var t: Transform3D = to_level * cs.global_transform
		var half: Vector3 = (cs.shape as BoxShape3D).size * 0.5
		var planes := PackedFloat32Array()
		for i in 3:
			var col: Vector3 = t.basis[i]
			var nrm := col.normalized()
			var ext: float = half[i] * col.length()
			var dc: float = nrm.dot(t.origin)
			planes.append_array(PackedFloat32Array([-nrm.x, -nrm.y, -nrm.z, -dc + ext]))
			planes.append_array(PackedFloat32Array([nrm.x, nrm.y, nrm.z, dc + ext]))
		if int(level.add_brush(planes, 0, PackedInt32Array(), SCENE_LEVEL_ID)) <= 0:
			push_warning("[MapDCLevel] scene box %s encloses nothing — left as a scene node" % e[0])
			continue
		for k in body.get_children():
			if k == cs or k is CSGShape3D or k is MeshInstance3D:
				continue
			var g := (k as Node3D).global_transform if k is Node3D else Transform3D.IDENTITY
			body.remove_child(k)
			add_child(k)
			k.set_meta("scene_content", true)     # authored, so adoptable (_is_scene_content)
			if k is Node3D:
				(k as Node3D).global_transform = g
		remove_child(body)
		body.queue_free()
		n += 1
	if n > 0:
		level.rebuild()
		print("[MapDCLevel] baked %d scene box(es) into base brushes (level %d)" % [n, SCENE_LEVEL_ID])
	return n


## EVERYTHING ELSE THE MAP AUTHORED IS AN OBJECT TOO (user 2026-10-05: "wallrunrange
## is undeletable ? everything should be able to be deleted"). Any other node the
## .tscn put directly under the map — the wall-run range, a label lifted off a baked
## box — is adopted as a generic base object: no props, but select / move / delete /
## undo through the journal like any other, its children going with it. NOT the level
## itself (DCLevel, DCTerrain): that is what the brushes live in, not a thing in it.
## Authored only (owner == the map, or marked by the box bake): by the time the import
## path adopts, the map also holds RUNTIME children (rt lights, entities, editor nodes)
## that are not content and are rebuilt every load.
const SCENE_NODE_TYPE := "scene.node"


func _is_scene_content(c: Node) -> bool:
	if not (c is Node3D) or c == level or c.is_class("DCLevel") or c.is_class("DCTerrain"):
		return false
	return c.owner == self or c.has_meta("scene_content")


## TELEPORTERS AND SPAWN POINTS BECOME OBJECTS (2026-10-05). The map's own teleporter
## instances and spawn_points Marker3Ds are ADOPTED by SceneObjects as base objects,
## dot "scene:<Name>", so they can be selected, moved, edited and deleted through the
## journal like placed objects (scene_objects.gd add_base). Before the first fold.
func _adopt_scene_objects() -> int:
	var objs := _ensure_objects()
	if objs == null:
		return 0
	var tele_scene := String(PrefabCatalogue.PREFABS.get("teleporter", ""))
	var n := 0
	for c in get_children():
		if not (c is Node3D) or c == objs:
			continue
		var type_id := ""
		if c.scene_file_path == tele_scene or "target_map" in c:
			type_id = "teleporter"
		elif c is Marker3D and c.is_in_group("spawn_points"):
			type_id = "spawn_point"
		elif _is_scene_content(c):
			type_id = SCENE_NODE_TYPE
		if type_id == "":
			continue
		objs.add_base("scene:%s" % c.name, type_id, c as Node3D)
		n += 1
	if n > 0:
		print("[MapDCLevel] adopted %d scene object(s) (teleporters, spawns, authored nodes)" % n)
	return n


## level_import OFF: an EMPTY level that is otherwise the same editor.
##
## Everything _import_from does after the import that is not about START.MAP still
## has to happen, or the editor is half-wired: the brush wireframe, the outliner
## (registered after import there because it counts brushes) and a first fold, so a
## room that already holds brushes for this map replays them on entry. The journal
## needs no level source for that -- records() accepts an empty src on both sides.
##
## PACK-TEXTURED (#94 P1, design §8): dc_level.gdshader fed by the texture packs
## and DCLevel's derived projection table, kept current by LevelPackMaterial.sync()
## from _process. Faces nobody has textured draw the same grey as before.
##
## Falls back to a PLAIN material when the loaded dc_terrain build predates the
## texture API. Not vertex colour: DCLevel packs material_id into COLOR.r, which
## would paint every brush red.
func _start_empty_level() -> void:
	var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
	if mi != null:
		_packs = LevelPackMaterial.new()
		if _packs.install(level, mi, int(Settings.resolved_value("texture_res")),
				int(Settings.resolved_value("surface_detail"))):
			Settings.setting_changed.connect(_on_texture_setting)
			_register_texture_strip()
		else:
			_packs = null
			var mat := StandardMaterial3D.new()
			mat.albedo_color = LevelPackMaterial.PLAIN
			mat.roughness = 0.9
			LevelPackMaterial.wear(level, mat)     # LevelMesh AND LevelPockets
			print("[MapDCLevel] dc_terrain build has no texture API — plain material")
	_refresh_brush_view()
	_register_outliner()
	_register_asset_browser()
	var st: Dictionary = _persist.refold() if _persist != null else {}
	print("[MapDCLevel] empty level (level_import off) — %d brush record(s) replayed"
			% int(st.get("applied", 0)))


## The empty level's pack material; null on the .MAP import path and on an old .so.
var _packs: LevelPackMaterial = null

## The player's CURRENT texture (#94 P1 §6): what every new brush face gets. Per
## player and not durable — it is a tool setting, like the grid size. "" = the pack
## default (the first Kenney grid). The texture browser / eyedropper (§9) set it.
var current_tex: String = ""

## Texture lock (#94 P1 D5, ON by default): moving or scaling a brush carries its
## textures with it. Per player, like current_tex; the flag rides each record.
var tex_lock: bool = true


## Texture intent for a new face: the current texture, paraxial, unscaled. Same
## Dictionary shape a brush.create record and DCLevel.add_brush carry.
func current_face_tex() -> Dictionary:
	return {"tex": current_tex if current_tex != "" else TexturePacks.default_id(), "mode": 1}


## The texture strip (bottom row of the BuildDock); null on maps without packs.
var _tex_strip: Node = null
## The Face panel (UV controls, right column); same lifetime as the strip.
var _face_panel: Node = null

## The editor bars (redesign E1): top bar (menus, select modes, grid, lock) and toolbar.
var _top_bar: Node = null
var _tool_settings: Node = null
var _history: Node = null
var _view: Node = null
var _cursor3d: Node3D = null
var _object_panel: Node = null
var _shape_panel: Node = null
var _stack_panel: Node = null
var _bot_panel: Node = null
var _tool_bar: Node = null
var _palette: Node = null
var _tool_popup: Node = null
var _context_menu: Node = null
var _q_menu: Node = null


func _register_editor_bars() -> void:
	var dock := get_node_or_null("/root/BuildDock")
	if dock == null:
		return
	var top := HFlowContainer.new()
	top.set_script(load("res://scenes/game/editor_top_bar.gd"))
	top.setup(self)
	dock.register("topbar", "Editor", top, true, dock.SIDE_TOP)
	_top_bar = top
	# E4: the active tool's settings, generated from its tool_settings(), under the top bar.
	var ts := HFlowContainer.new()
	ts.set_script(load("res://scenes/game/tool_settings_bar.gd"))
	ts.setup(self)
	dock.register("toolsettings", "Tool", ts, true, dock.SIDE_TOP, dock.WS_LEVEL)
	_tool_settings = ts
	# And its readout under the crosshair ("+64u X"), polled by the HUD like the status bar.
	if BuildHud != null and "readout_provider" in BuildHud:
		BuildHud.readout_provider = Callable(self, "_op_readout")
	# Orthographic + Blender's numpad views (#109, docs/editor-ortho-view.md).
	_view = Node.new()
	_view.name = "EditorView"
	_view.set_script(load("res://scenes/game/editor_view.gd"))
	add_child(_view)
	_view.setup(self)
	# The 3D cursor (#109, docs/editor-3d-cursor.md).
	_cursor3d = Node3D.new()
	_cursor3d.set_script(load("res://scenes/game/editor_cursor.gd"))
	add_child(_cursor3d)
	_cursor3d.setup(self)
	# E5: the History panel (right) and Adjust Last (left), both reading the journal.
	_ensure_persistence()
	_history = Node.new()
	_history.name = "EditorHistory"
	_history.set_script(load("res://scenes/game/editor_history.gd"))
	add_child(_history)
	_history.setup(self)
	# The selected scene object's properties (object.set_prop). Every map with objects,
	# so here with the bars rather than with the pack-only Face panel.
	_object_panel = VBoxContainer.new()
	_object_panel.set_script(load("res://scenes/game/object_panel.gd"))
	_object_panel.setup(self)
	dock.register("object", "Object", _object_panel, true, dock.SIDE_RIGHT)
	# The selected parametric shape's params, Make Real and Delete (parametric-shapes §4).
	_shape_panel = VBoxContainer.new()
	_shape_panel.set_script(load("res://scenes/game/shape_panel.gd"))
	_shape_panel.setup(self)
	dock.register("shape", "Shape", _shape_panel, true, dock.SIDE_RIGHT, dock.WS_LEVEL)
	# The selected thing's live cuts, under it (docs/cutting-and-operations.md §9b).
	_stack_panel = VBoxContainer.new()
	_stack_panel.set_script(load("res://scenes/game/stack_panel.gd"))
	_stack_panel.setup(self)
	dock.register("stack", "Stack", _stack_panel, true, dock.SIDE_RIGHT, dock.WS_LEVEL)
	var tools := HFlowContainer.new()
	tools.set_script(load("res://scenes/game/editor_tool_bar.gd"))
	tools.setup(self)
	# FIRST in the left column — the tools are what you reach for most.
	dock.register("tools", "Tools", tools, true, dock.SIDE_LEFT, dock.WS_LEVEL)
	var col: Node = dock._sides[dock.SIDE_LEFT]["column"]
	var frame: Node = (dock._panels["tools"]["section"] as Node).get_parent()
	col.move_child(frame, 0)
	_tool_bar = tools
	# F3 command palette (E3): every reachable command by name, run through Keymap.run.
	_palette = CanvasLayer.new()
	_palette.name = "CommandPalette"          # its Outliner row, not "@CanvasLayer@322"
	_palette.set_script(load("res://scenes/game/command_palette.gd"))
	_palette.setup(self)
	add_child(_palette)
	# Shift+Space tool popup (E3c) and RMB-tap context menu (E3d): one popup script.
	_tool_popup = Node.new()
	_tool_popup.set_script(load("res://scenes/game/action_popup.gd"))
	add_child(_tool_popup)
	_tool_popup.setup("ToolPopup")
	_context_menu = Node.new()
	_context_menu.set_script(load("res://scenes/game/action_popup.gd"))
	add_child(_context_menu)
	_context_menu.setup("ContextMenu")
	# The Q menu (HardOps; docs/cutting-and-operations.md §9c): the same popup, rows by
	# what is selected (q_sections).
	_q_menu = Node.new()
	_q_menu.set_script(load("res://scenes/game/action_popup.gd"))
	add_child(_q_menu)
	_q_menu.setup("QMenu")
	if not GameMode.look_tapped.is_connected(_on_look_tapped):
		GameMode.look_tapped.connect(_on_look_tapped)
	var hud := get_node_or_null("/root/BuildHud")
	if hud != null:
		hud.info_provider = _status_info
	# The BOT EDITOR workspace's panel (user, 2026-10-04), then the last workspace used.
	var bots := VBoxContainer.new()
	bots.set_script(load("res://scenes/game/bot_editor_panel.gd"))
	bots.setup(self)
	dock.register("bots", "Bots", bots, true, dock.SIDE_LEFT, dock.WS_BOT)
	_bot_panel = bots
	dock.set_workspace(String(dock.pref("workspace", dock.WS_LEVEL)))


## RMB TAP -> the context menu (E3d). Only when no tool is up: while an operator runs
## the map takes RMB first (cancel), so GameMode never sees that tap anyway.
func _on_look_tapped(at: Vector2) -> void:
	if _context_menu == null or not GameMode.is_building() or level == null:
		return
	if _ops != null and _ops.active != null:
		return
	_context_menu.open_sections(context_sections(), at)


## What the context menu offers, by SELECTION KIND (docs/editor-redesign.md §5.5):
## element (face / edge / vertex) ops, brush ops (+ CSG for two or more), then what is
## always available. Every row is a keymap action, so it shows and runs its key.
func context_sections() -> Array:
	var secs: Array = []
	if _sel_kind != "":
		var el: Array = [["builder.move"], ["builder.move_in"], ["builder.move_out"]]
		if _sel_kind == "face":
			el.append_array([["builder.extrude"], ["builder.texture_apply"], ["builder.texture_pick"],
				["build_mode.faces_extrude"]])
			if _sel_faces.size() >= 2:
				el.append(["build_mode.faces_hull"])
				el.append(["builder.cable_bundle"])     # #120 L3 §14.6
		if _sel_kind == "edge":
			el.append(["builder.cable_from_edges"])     # #120 L3 §14.5
		el.append(["builder.deselect"])
		secs.append([_sel_kind.capitalize(), el])
	if not _sel_brushes.is_empty():
		var br: Array = [["builder.move"], ["builder.duplicate"], ["builder.duplicate_linked"], ["builder.texture_apply"], ["builder.delete"]]
		if _sel_brushes.size() >= 2:
			br.append_array([["build_mode.csg_subtract"], ["build_mode.csg_intersect"], ["build_mode.csg_merge"],
				["build_mode.csg_discard"]])
		br.append(["builder.deselect"])
		secs.append(["Brush" if _sel_brushes.size() == 1 else "%d brushes" % _sel_brushes.size(), br])
	secs.append(["Add & view", [["builder.create_box"], ["builder.knife"], ["builder.carve"],
		["builder.snap_toggle"], ["builder.grid_coarser"], ["builder.grid_finer"], ["builder.palette"]]])
	secs.append(cursor_section())
	return secs


## The 3D cursor's snap commands (Blender's Shift+S menu; editor_cursor.gd).
func cursor_section() -> Array:
	return ["3D cursor", [["cursor.to_selected"], ["cursor.to_active"], ["cursor.to_grid"], ["cursor.to_origin"]]]


## THE Q MENU's rows, by selection (docs/cutting-and-operations.md §9c).
func q_sections() -> Array:
	if _sel_kind in ["face", "edge", "vertex"]:
		var cs: Array = context_sections()
		return [cs[0]] if not cs.is_empty() else []
	if _sel_brushes.is_empty():
		return [["Add", [["builder.create_box"]]], cursor_section()]
	if _sel_asset != "":
		# A PLACED ASSET is one thing (#120 L4): its pieces are read-only.
		return [["Placed asset", [["builder.make_real"], ["builder.duplicate"], ["builder.delete"]]],
			["Selection", [["builder.hide"], ["builder.hide_unselected"]]]]
	var things := {}
	for b: int in _sel_brushes:
		var g := shape_of_brush(b)
		if g == "":
			g = String(level.get_brush_group(b))
		things[g if g != "" else "brush:%d" % b] = true
	if things.size() >= 2:
		return [["Operations (active = target)", [["build_mode.csg_subtract"], ["build_mode.csg_intersect"],
				["build_mode.csg_slice"], ["build_mode.csg_merge"], ["build_mode.csg_discard"]]],
			["Selection", [["builder.hide"], ["builder.hide_unselected"], ["builder.delete"]]]]
	var subject := stack_subject()
	if not cuts_by(subject).is_empty():
		return [["Cutter", [["builder.cut_as_subtract"], ["builder.cut_as_intersect"], ["builder.cut_as_slice"],
				["builder.select_cut_target"], ["builder.cuts_clear"]]],
			["Selection", [["builder.hide"], ["builder.hide_unselected"]]]]
	_ensure_persistence()
	var is_shape: bool = (_persist.live_shapes() as Dictionary).has(subject)
	var rows: Array = []
	if is_shape:
		rows.append(["builder.assign_generator"])
		rows.append(["builder.add_modifier"])
		rows.append(["builder.make_real"])
		if String(_persist.shape_state(subject).get("type", "")) == "cable":
			rows.append(["builder.drop_cable"])          # #120 L3 §14.3
	else:
		rows.append(["builder.make_parametric"])
	if not op_stack(subject).is_empty():
		rows.append(["builder.cuts_clear"])
		# #120 L2: the stack operations (StackOps); Apply works on shapes, as Make Real does.
		rows.append(["builder.change_cut"])
		rows.append(["builder.extract_cut"])
		if is_shape:
			rows.append(["builder.apply_up_to"])
	rows.append_array([["builder.hide"], ["builder.hide_unselected"], ["builder.delete"]])
	return [["Shape" if is_shape else "Selection", rows], cursor_section()]


## ---- KEYMAP STEP 3 commands (docs/keymap-blender-279.md §3a) ----

## The parametric shape types the add menu offers (param_shapes ids, tab order).
const ADD_SHAPES := ["box", "stairs", "spiral_stairs", "tunnel", "cylinder", "cone", "sphere"]
## What Shift+R repeats: the commands that start a tool or apply an edit (not views, not
## selection, not menus). Recorded as they fire (_note_repeatable).
const REPEATABLE := ["builder.move", "builder.rotate", "builder.scale", "builder.extrude",
	"builder.duplicate", "builder.duplicate_linked", "builder.knife", "builder.create_box", "builder.box_cutter", "builder.texture_apply",
	"build_mode.csg_subtract", "build_mode.csg_intersect", "build_mode.csg_slice",
	"build_mode.csg_merge", "builder.uv_hotspot", "builder.uv_fit", "builder.uv_align"]
var _last_repeatable := ""
## The element mode Tab returns to from brush mode (1-3).
var _last_element_mode := "face"


func _note_repeatable(event: InputEvent) -> void:
	for a: String in REPEATABLE:
		if Keymap.tapped(event, a):
			_last_repeatable = a
			return


## One select-mode switch for the 1-4 keys and Tab.
func set_select_mode(mode: String) -> void:
	if not SEL_MODES.has(mode):
		return
	if mode != "brush":
		_last_element_mode = mode
	if _sel_mode != mode:
		_sel_mode = mode
		# CLEARED, not converted. Blender converts a selection between modes (a face
		# becomes its four vertices); here a selection IS a set of incident planes, and
		# converting one would silently change what the next drag moves. Clearing says so.
		# A selected CURVE is kept (§17.7 Q4): it is a whole object, and the mode is what
		# decides whether G edits its points (1) or moves it (4).
		var keep_curve := _sel_curve
		_clear_face_selection()
		if keep_curve != "":
			select_curve(keep_curve)
	_sync_handle_visibility()
	_update_op_hud()
	print("[MapDCLevel] select mode: %s" % mode)


## ---- THE PIVOT (keymap step 6.2; Blender 2.79's transform_pivot_point) ----
##   ,  bounding box centre (the default, and the old rotate centre)   Ctrl+,  median point
##   .  the 3D cursor                                                  Alt+.   active element
## Ctrl+. = INDIVIDUAL ORIGINS (#109 step 6): each UNIT turns / scales about its own
## centre, preview included — a parametric shape is one unit (its pieces together), a
## plain brush is one, an object turns about its own origin. See individual_units.
const PIVOT_MODES := ["bbox", "median", "cursor", "active", "individual"]
const PIVOT_NAMES := ["Bounding box", "Median", "3D cursor", "Active", "Individual origins"]
var pivot_mode := "bbox"


func set_pivot_mode(m: String) -> void:
	if not PIVOT_MODES.has(m):
		return
	pivot_mode = m
	if BuildDock != null:
		BuildDock.set_pref("pivot_mode", m)
	print("[MapDCLevel] pivot: %s" % PIVOT_NAMES[PIVOT_MODES.find(m)])


func pivot_index() -> int:
	return PIVOT_MODES.find(pivot_mode)


func pivot_individual() -> bool:
	return pivot_mode == "individual"


## ---- PROPORTIONAL EDITING (O; Blender use_proportional_edit_objects; design on
## hyper-games #109, 2026-10-13) --------------------------------------------------
##
## With it on, G / R / S move what is NEAR the selection too, by a falloff of its
## distance: w = falloff(1 - d / radius), d from the nearest selected unit. A unit is
## what moves as one — a plain brush, a parametric shape (its pieces together), a scene
## object — so every brush stays a valid convex solid. Keys as Blender 2.7x: O toggles,
## Shift+O cycles the falloff, Alt+O connected-only (the element level's); PgUp / PgDn
## size the radius while a tool is up.
const PROP_FALLOFFS: Array[String] = ["smooth", "sphere", "root", "sharp", "linear", "constant"]
var proportional := false
var prop_falloff := "smooth"
var prop_radius := 8.0               # metres
var prop_connected := false


func toggle_proportional() -> void:
	proportional = not proportional
	print("[MapDCLevel] proportional editing %s (%s, radius %.1f m)" % ["ON" if proportional else "off",
		prop_falloff, prop_radius])
	_update_op_hud()


func cycle_prop_falloff() -> void:
	prop_falloff = PROP_FALLOFFS[(PROP_FALLOFFS.find(prop_falloff) + 1) % PROP_FALLOFFS.size()]
	print("[MapDCLevel] proportional falloff: %s" % prop_falloff)
	_update_op_hud()


func toggle_prop_connected() -> void:
	prop_connected = not prop_connected
	print("[MapDCLevel] proportional: %s" % ("connected only" if prop_connected else "everything in range"))
	_update_op_hud()


## Blender's falloffs of t = 1 - d / r (1 at the selection, 0 at the radius).
static func prop_weight(falloff: String, d: float, r: float) -> float:
	if r <= 0.0 or d > r or (d >= r and falloff != "constant"):
		return 0.0
	var t := 1.0 - d / r
	match falloff:
		"sharp":
			return t * t
		"root":
			return sqrt(t)
		"linear":
			return t
		"constant":
			return 1.0
		"sphere":
			return sqrt(maxf(0.0, 2.0 * t - t * t))
	return 3.0 * t * t - 2.0 * t * t * t       # smooth


## The units near the selection, weighted: [{kind: "brush"|"shape"|"object", key | shape |
## dot, w}]. `sel_ids` are selected brush ids (a shape's pieces count as the shape),
## `sel_dots` selected objects. Distances are WORLD-space: brushes live in the level
## store's space, objects in SceneObjects' — measured where they actually are.
func proportional_neighbours(sel_ids: Array, sel_dots: Array) -> Array:
	var out: Array = []
	if not proportional or level == null:
		return out
	var lg := (level as Node3D).global_transform
	var from := PackedVector3Array()
	var skip_brush := {}
	var skip_shape := {}
	for u: Dictionary in individual_units(sel_ids):
		from.append(lg * (u["centre"] as Vector3))
		if String(u["shape"]) != "":
			skip_shape[String(u["shape"])] = true
		for b: int in (u["ids"] as PackedInt32Array):
			skip_brush[b] = true
	for d: String in sel_dots:
		var n := object_node(d) as Node3D
		if n != null:
			from.append(n.global_position)
	if from.is_empty():
		return out
	var near := func(p: Vector3) -> float:
		var best := INF
		for f: Vector3 in from:
			best = minf(best, p.distance_to(f))
		return best
	var others: Array = []
	for b: int in level.get_live_brush_ids():
		if not skip_brush.has(b) and not bool(level.get_brush(b).get("clearance", false)):
			others.append(b)
	for u: Dictionary in individual_units(others):
		if String(u["shape"]) != "" and skip_shape.has(String(u["shape"])):
			continue
		var w := prop_weight(prop_falloff, near.call(lg * (u["centre"] as Vector3)), prop_radius)
		if w > 0.0:
			out.append({"kind": "shape" if String(u["shape"]) != "" else "brush", "shape": u["shape"],
				"key": u["key"], "w": w})
	if _objects != null and is_instance_valid(_objects):
		for e: Dictionary in _objects.entries():
			var dot := String(e["dot"])
			if sel_dots.has(dot) or String(e["parent"]) != "":
				continue                     # a child rides with its parent
			var w2 := prop_weight(prop_falloff, near.call((e["node"] as Node3D).global_position), prop_radius)
			if w2 > 0.0:
				out.append({"kind": "object", "dot": dot, "w": w2})
	return out


## Move proportional neighbours (brushes and shapes) by their weight × `delta`. Objects
## are the caller's (they move with the selection's object path). The operator stack
## wraps the confirm in one gesture: one Ctrl+Z takes all of it back.
func translate_units(units: Array, delta: Vector3) -> int:
	var n := 0
	for u: Dictionary in units:
		var d := delta * float(u["w"])
		match String(u["kind"]):
			"shape":
				var sd := String(u["shape"])
				var f: Transform3D = _persist.shape_state(sd)["frame"]
				if _record_shape_frame(sd, Transform3D(f.basis, f.origin + d)):
					n += 1
			"brush":
				var b: int = level.brush_by_key(String(u["key"]))
				if b >= 0 and level.translate_brush(b, d, tex_lock):
					_persist.record("brush.translate", {"brush_key": String(u["key"]),
						"delta": [d.x, d.y, d.z], "tex_lock": tex_lock})
					n += 1
	if n > 0:
		_persist.refold()
		_refresh_brush_view()
	return n


## Turn ("rotate": `axis`, `deg`) or scale ("scale": `factor`) proportional neighbours
## about `pivot`, each by its weight: w·deg, or factor lerped from 1 by w. Each unit is
## resolved by its durable name at its turn — every rotate / scale refolds, and a refold
## can renumber the ids. Objects are the caller's. One gesture: the operator stack's.
func transform_units(units: Array, op: String, axis: Vector3, deg: float, factor: Vector3, pivot: Vector3) -> int:
	var n := 0
	for u: Dictionary in units:
		var ids: Array = []
		match String(u["kind"]):
			"shape":
				ids = Array(level.get_group_brush_ids(String(u["shape"])))
			"brush":
				var b: int = level.brush_by_key(String(u["key"]))
				if b >= 0:
					ids = [b]
		if ids.is_empty():
			continue
		var w := float(u["w"])
		if op == "rotate":
			n += rotate_brushes(ids, axis, deg * w, pivot)
		else:
			n += scale_brushes(ids, Vector3.ONE.lerp(factor, w), pivot)
	return n


## PROPORTIONAL, ELEMENT LEVEL (#109; user 2026-10-13: "also element vertices"). The
## corners near a part about to move — collected BEFORE it moves — as [brush key, corner,
## w]: on its own brush, and (unless connected-only, Alt+O) on the brushes beside it, so
## shared seams follow. `src` = the part's own corners (they move with the part; never
## counted twice). A shape's pieces are skipped: read-only (guard_shape_part).
func prop_corners(src: PackedVector3Array, own: int) -> Array:
	var out: Array = []
	if not proportional or level == null or src.is_empty():
		return out
	var brushes: Array = [own] if prop_connected else Array(level.get_live_brush_ids())
	for b: int in brushes:
		if b < 0 or bool(level.get_brush(b).get("clearance", false)) or shape_of_brush(b) != "":
			continue
		var key := String(level.get_brush_key(b))
		for v: Vector3 in level.get_brush_vertices_of(b):
			var best := INF
			for s: Vector3 in src:
				best = minf(best, v.distance_to(s))
			if best < 1.0e-3:
				continue                         # one of the part's own corners
			var w := prop_weight(prop_falloff, best, prop_radius)
			if w > 0.0:
				out.append([key, v, w])
	return out


## Move each collected corner by w·delta — one brush.reshape (re-hull) each, as element
## rotate moves corners. A corner the part's own move already carried (a face or a
## nudge moves planes, which drags corners) is no longer where it was: skipped.
func prop_move_corners(corners: Array, delta: Vector3) -> int:
	var n := 0
	for e: Array in corners:
		var b: int = level.brush_by_key(String(e[0]))
		if b < 0:
			continue
		var c: Vector3 = e[1]
		var still := false
		for v: Vector3 in level.get_brush_vertices_of(b):
			if v.distance_to(c) < 1.0e-3:
				still = true
				break
		if not still:
			continue
		var dl: Vector3 = delta * float(e[2])
		var dot: String = _persist.reserve_dot()
		if level.move_brush_points(b, PackedVector3Array([c]), dl, dot):
			_persist.record_at(dot, "brush.reshape", {"brush_key": String(e[0]),
				"points": [[c.x, c.y, c.z]], "delta": [dl.x, dl.y, dl.z]})
			n += 1
	if n > 0:
		level.rebuild()
		_refresh_brush_view()
	return n


## The selected part's own corners: a vertex / edge's drag points, a face's polygon.
func element_corners() -> PackedVector3Array:
	var pts: PackedVector3Array = element_drag_points()
	if pts.is_empty() and _sel_kind == "face":
		var idx: PackedInt32Array = element_planes()
		if not idx.is_empty():
			pts = level.get_face_winding(_sel_brush, idx[0])
	return pts


## The UNITS among brushes `ids` for individual origins, each with its own centre (the
## centre of its non-clearance bounds), in the level store's space:
##   [{"key": brush key or "", "shape": dot or "", "ids": PackedInt32Array, "centre": Vector3,
##     "size": Vector3 (those bounds' size)}]
## Held by DURABLE names: a shape edit refolds the level and renumbers brush ids, so a
## caller acting on several units in turn re-resolves each one (unit_ids) at its turn.
func individual_units(ids: Array) -> Array:
	var out: Array = []
	var sp: Array = _split_shapes(ids)
	for d: String in (sp[0] as Dictionary):
		var pieces: PackedInt32Array = level.get_group_brush_ids(d)
		var bs := _unit_bounds(pieces)
		out.append({"key": "", "shape": d, "ids": pieces, "centre": bs.get_center(), "size": bs.size})
	for pd: String in (sp[2] as Dictionary):             # a placed asset is one unit (#120 L4)
		var aps := asset_brush_ids(pd)
		var ab := _unit_bounds(aps)
		out.append({"key": "", "shape": "", "asset": pd, "ids": aps, "centre": ab.get_center(), "size": ab.size})
	for b: int in (sp[1] as Array):
		var one := PackedInt32Array([b])
		var bb := _unit_bounds(one)
		out.append({"key": String(level.get_brush_key(b)), "shape": "", "ids": one,
			"centre": bb.get_center(), "size": bb.size})
	return out


## A unit's CURRENT brush ids (re-resolved by its durable name).
func unit_ids(u: Dictionary) -> PackedInt32Array:
	if String(u.get("asset", "")) != "":
		return asset_brush_ids(String(u["asset"]))
	if String(u["shape"]) != "":
		return level.get_group_brush_ids(String(u["shape"]))
	var b: int = level.brush_by_key(String(u["key"]))
	return PackedInt32Array([b]) if b >= 0 else PackedInt32Array()


func _unit_bounds(ids: PackedInt32Array) -> AABB:
	var lo := Vector3.INF
	var hi := -Vector3.INF
	for b: int in ids:
		var d: Dictionary = level.get_brush(b)
		if not d.has("aabb_min") or bool(d.get("clearance", false)):
			continue
		lo = lo.min(d["aabb_min"] as Vector3)
		hi = hi.max(d["aabb_max"] as Vector3)
	return AABB(lo, hi - lo) if lo.x != INF else AABB()


## Turn every unit about its OWN centre (one gesture — the operator stack's).
func rotate_individual(ids: Array, axis: Vector3, deg: float) -> int:
	var n := 0
	for u: Dictionary in individual_units(ids):
		var now := unit_ids(u)
		if not now.is_empty():
			n += rotate_brushes(Array(now), axis, deg, u["centre"])
	return n


## Scale every unit (individual_units, read BEFORE the first refold) about its OWN centre,
## unit i by factors[i] — each its own grid step. `local`: along the shape's own axes.
## One gesture — the operator stack's.
func scale_individual(units: Array, factors: Array, local := false) -> int:
	var n := 0
	for i in mini(units.size(), factors.size()):
		var u: Dictionary = units[i]
		var now := unit_ids(u)
		if now.is_empty():
			continue
		if local:
			n += scale_shapes_local(Array(now), factors[i], u["centre"])
		else:
			n += scale_brushes(Array(now), factors[i], u["centre"])
	return n


## The pivot for brushes `ids`, in the LEVEL STORE's space (where brush planes live).
func pivot_point(ids: Array) -> Vector3:
	var lo := Vector3.INF
	var hi := -Vector3.INF
	var sum := Vector3.ZERO
	var n := 0
	for b: Variant in ids:
		var d: Dictionary = level.get_brush(int(b))
		if not d.has("aabb_min"):
			continue
		lo = lo.min(d["aabb_min"] as Vector3)
		hi = hi.max(d["aabb_max"] as Vector3)
		sum += ((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5
		n += 1
	var bbox := (lo + hi) * 0.5 if n > 0 else Vector3.ZERO
	match pivot_mode:
		"median":
			return sum / float(n) if n > 0 else bbox
		"cursor":
			if _cursor3d != null:
				return (level as Node3D).global_transform.affine_inverse() * _cursor3d.global_position
		"active":
			if _sel_brush >= 0 and ids.has(_sel_brush):
				var a: Dictionary = level.get_brush(_sel_brush)
				if a.has("aabb_min"):
					return ((a["aabb_min"] as Vector3) + (a["aabb_max"] as Vector3)) * 0.5
	return bbox


## The pivot for scene OBJECTS by their origins (scene space); `active` = the active one's.
func pivot_point_objects(origins: Array, active: Variant = null) -> Vector3:
	var lo := Vector3.INF
	var hi := -Vector3.INF
	var sum := Vector3.ZERO
	for o: Vector3 in origins:
		lo = lo.min(o)
		hi = hi.max(o)
		sum += o
	var bbox := (lo + hi) * 0.5 if not origins.is_empty() else Vector3.ZERO
	match pivot_mode:
		"median":
			return sum / float(origins.size()) if not origins.is_empty() else bbox
		"cursor":
			if _cursor3d != null:
				var space: Node3D = _objects as Node3D if _objects is Node3D else null
				return space.global_transform.affine_inverse() * _cursor3d.global_position if space != null \
					else _cursor3d.global_position
		"active":
			if active is Vector3:
				return active
	return bbox


## ---- SELECT LINKED / GROUPED (keymap step 6.3) ----

## A brush's "linked" unit: every live brush of its group (a shape's pieces, an operator
## group), else just itself.
func _unit_of(b: int) -> PackedInt32Array:
	var g := String(level.get_brush_group(b))
	if g != "":
		var ids: PackedInt32Array = level.get_group_brush_ids(g)
		if not ids.is_empty():
			return ids
	return PackedInt32Array([b])


## L / Shift+L: add (remove) the shape / group under the POINTER to (from) the selection.
func select_linked_pick(deselect := false) -> int:
	var hit: Dictionary = _crosshair_hit(get_viewport().get_mouse_position())
	var b: int = _brush_from_hit(hit) if not hit.is_empty() else -1
	if b < 0:
		print("[MapDCLevel] select linked: nothing under the pointer")
		return 0
	var unit := _unit_of(b)
	var out := PackedInt32Array(_sel_brushes)
	for id: int in unit:
		if deselect:
			var at := out.find(id)
			if at >= 0:
				out.remove_at(at)
		elif not out.has(id):
			out.append(id)
	select_brushes(out)
	return unit.size()


## Ctrl+L: grow the selection to EVERYTHING TOUCHING IT, transitively (AABB contact — the
## store's own `connected` test). Bucketed on a coarse grid so a big level stays fast.
func select_linked() -> int:
	if level == null or _sel_brushes.is_empty():
		return 0
	const CELL := 4.0
	var box := {}
	var buckets := {}
	for id: int in (level.get_live_brush_ids() as PackedInt32Array):
		var d: Dictionary = level.get_brush(id)
		if not d.has("aabb_min") or bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
			continue
		var lo: Vector3 = d["aabb_min"]
		var hi: Vector3 = d["aabb_max"]
		box[id] = [lo, hi]
		for cx in range(floori(lo.x / CELL), floori(hi.x / CELL) + 1):
			for cz in range(floori(lo.z / CELL), floori(hi.z / CELL) + 1):
				var k := Vector2i(cx, cz)
				if not buckets.has(k):
					buckets[k] = []
				(buckets[k] as Array).append(id)
	var seen := {}
	var queue: Array = []
	for b: int in _sel_brushes:
		if box.has(b):
			seen[b] = true
			queue.append(b)
	const EPS := 1e-3
	while not queue.is_empty():
		var a: int = queue.pop_back()
		var alo: Vector3 = box[a][0]
		var ahi: Vector3 = box[a][1]
		for cx in range(floori(alo.x / CELL), floori(ahi.x / CELL) + 1):
			for cz in range(floori(alo.z / CELL), floori(ahi.z / CELL) + 1):
				for c: int in (buckets.get(Vector2i(cx, cz), []) as Array):
					if seen.has(c):
						continue
					var blo: Vector3 = box[c][0]
					var bhi: Vector3 = box[c][1]
					if ahi.x + EPS < blo.x or alo.x - EPS > bhi.x or ahi.y + EPS < blo.y \
							or alo.y - EPS > bhi.y or ahi.z + EPS < blo.z or alo.z - EPS > bhi.z:
						continue
					seen[c] = true
					queue.append(c)
	select_brushes(PackedInt32Array(seen.keys()))
	print("[MapDCLevel] select linked: %d brush(es) touching the selection" % seen.size())
	return seen.size()


## X (step 6.4): Blender's confirming delete — the popup IS the confirm, its row names what
## goes. Clear cuts rides along when the selection carries live cuts.
func delete_sections() -> Array:
	var n := _sel_objects.size() + _sel_brushes.size()
	if n == 0:
		return [["Delete", [["builder.delete", "Delete the aimed brush"]]]]
	var rows: Array = [["builder.delete", "Delete %d selected" % n]]
	# X keeps an object's children (#120 F1); Delete Hierarchy takes them too, offered
	# only when a selected object has any.
	if _objects != null and is_instance_valid(_objects):
		for e: Dictionary in (_objects.entries() as Array):
			if _sel_objects.has(String(e.get("parent", ""))):
				rows.append(["builder.delete_hierarchy"])
				break
	var subj := stack_subject()
	if subj != "" and (not op_stack(subj).is_empty() or not cuts_by(subj).is_empty()):
		rows.append(["builder.cuts_clear"])
	return [["Delete?", rows]]


## Ctrl+G / M / Shift+M / Ctrl+Alt+G / Ctrl+J / P (#120 F1). True if the event was one.
## M and Shift+M pick a collection from a list (Blender's, with + New Collection on top);
## Ctrl+Alt+G lists only the collections the selection is in.
func _collection_keys(event: InputEvent) -> bool:
	if Keymap.tapped(event, "builder.collection_new"):
		# Collection rows selected in the outliner are wrapped (a meta collection).
		var cols: Array = _outliner.selected_collections() if _outliner != null and is_instance_valid(_outliner) else []
		prompt_collection_name("New collection", func(n: String) -> void:
			var dot := CollectionOps.create_from_selection(self, n, "", cols)
			print("[MapDCLevel] new collection %s \"%s\" from the selection" % [dot, n]))
		return true
	if Keymap.tapped(event, "builder.save_asset"):
		save_asset_prompt()
		return true
	if Keymap.tapped(event, "builder.join"):
		CollectionOps.join(self)
		return true
	if Keymap.tapped(event, "builder.rename"):
		if _outliner != null and is_instance_valid(_outliner):
			_outliner.rename_selected()
		return true
	if Keymap.tapped(event, "builder.separate"):
		CollectionOps.separate(self)
		return true
	for spec: Array in [["builder.collection_move", "Move to collection", "move"],
			["builder.collection_link", "Link to collection", "link"],
			["builder.collection_remove", "Remove from collection", "remove"]]:
		if Keymap.tapped(event, String(spec[0])):
			if _q_menu != null:
				_q_menu.open_choices(String(spec[1]), collection_choices(String(spec[2])),
					get_viewport().get_mouse_position())
				get_viewport().set_input_as_handled()
			return true
	return false


## ---- ASSETS (#120 L4 §15.4) ----

const AssetLibraryScript := preload("res://scenes/maps/asset_library.gd")
var _asset_library: Node = null


## The room's + personal asset libraries (a child; created with the journal so placed
## assets' blobs are fetched whether or not a panel is open).
func asset_library() -> Node:
	_ensure_persistence()
	return _asset_library


## The collection Save as asset acts on: the outliner's selected collection row, else the
## first collection the selection is in. "" when neither.
func asset_collection() -> String:
	_ensure_persistence()
	var cols: Array = _outliner.selected_collections() if _outliner != null and is_instance_valid(_outliner) else []
	if not cols.is_empty():
		return String(cols[0])
	# The selection's members, READ ONLY (CollectionOps.selection_members regroups loose
	# brushes — a write — and asking which collection must not write).
	var ms: Array = []
	if _sel_asset != "":
		ms.append("a:" + _sel_asset)
	if _sel_shape != "":
		ms.append("g:" + _sel_shape)
	for d: Variant in selected_objects():
		ms.append("obj:" + String(d))
	for b: int in _sel_brushes:
		var g := String(level.get_brush_group(b)) if level != null else ""
		if g != "" and not ms.has("g:" + g):
			ms.append("g:" + g)
	var t: Dictionary = _persist.collection_tree()
	for m: String in ms:
		var cs: Array = CollectionFold.collections_of(t, m)
		if not cs.is_empty():
			return String(cs[0])
	return ""


## SAVE COLLECTION AS ASSET (menu only, user 2026-10-19): named first (Blender's popup,
## prefilled with the collection's name), then into the room's and your library.
func save_asset_prompt() -> void:
	var col := asset_collection()
	if col == "":
		print("[MapDCLevel] save as asset: select a collection (its outliner row, or something in it)")
		return
	var cname := String(((_persist.collection_tree()["cols"] as Dictionary)[col] as Dictionary)["name"])
	var make := func(n: String) -> void:
		var r: Dictionary = asset_library().save_collection(col, n, asset_save_room, asset_save_personal)
		for line: String in (r.get("report", []) as Array):
			print("[MapDCLevel] save as asset: %s" % line)
		if r.has("error"):
			print("[MapDCLevel] save as asset \"%s\": %s" % [n, r["error"]])
		else:
			print("[MapDCLevel] saved asset \"%s\" %s (%d of your placements updated)"
				% [n, String(r["oid"]).substr(0, 12), int(r["followed"])])
	if _q_menu == null or not _q_menu.has_method("open_name"):
		make.call(cname)
		return
	_q_menu.open_name("Save as asset", cname, make, get_viewport().get_mouse_position())
	get_viewport().set_input_as_handled()


## ---- the ASSET BROWSER's side (#120 L4 §15.5) ----

## The asset Shift+A → Asset places (a browser click picks it). Per player, a tool setting.
var current_asset := ""
## Where Save as asset writes: the browser's "Save to" switches. Both by default (§15.4).
var asset_save_room := true
var asset_save_personal := true
var _asset_browser: Node = null
var _asset_ghost: MeshInstance3D = null


## A placement frame for a drop on a surface (§15.5, user 2026-10-19): standing ON the hit,
## up = the hit normal snapped to the nearest world axis (floor stands, wall sticks out,
## ceiling hangs; a slope takes its nearest axis). Right-handed: x then z = x × up.
static func drop_frame(position: Vector3, normal: Vector3) -> Transform3D:
	var a := normal.abs()
	var up := Vector3.UP
	if a.x >= a.y and a.x >= a.z:
		up = Vector3(signf(normal.x), 0, 0)
	elif a.z >= a.y:
		up = Vector3(0, 0, signf(normal.z))
	else:
		up = Vector3(0, signf(normal.y) if normal.y != 0.0 else 1.0, 0)
	# x: world +X on a floor or ceiling; on a wall, world up × the wall normal (level).
	var x := Vector3.RIGHT if absf(up.y) > 0.5 else Vector3.UP.cross(up)
	return Transform3D(Basis(x, up, x.cross(up)), position)


## Place asset `name` on the surface `hit` ({position, normal}) — the drop. Selected after,
## so G / R adjust it. Returns the placement's dot, or "".
func place_asset_hit(name: String, hit: Dictionary) -> String:
	if hit.is_empty() or not hit.has("position"):
		return ""
	var d: String = asset_library().place(name, drop_frame(hit["position"], hit.get("normal", Vector3.UP)))
	if d != "":
		select_asset(d)
		print("[MapDCLevel] placed asset \"%s\" %s" % [name, d])
	return d


## The drop at screen point `at` (viewport coordinates): what is under the pointer.
func place_asset_at(name: String, at: Vector2) -> String:
	asset_ghost_hide()
	return place_asset_hit(name, _crosshair_hit(at))


## Shift+A → Asset (menu-only): the current asset at the 3D cursor, standing up.
func place_current_asset() -> String:
	if current_asset == "":
		print("[MapDCLevel] place asset: pick one in the Assets strip first")
		return ""
	var at: Vector3 = (_cursor3d as Node3D).global_position if _cursor3d is Node3D else Vector3.ZERO
	return place_asset_hit(current_asset, {"position": at, "normal": Vector3.UP})


## The drop GHOST while a tile is dragged over the view: the asset's bounds box standing
## where it would land. False (and hidden) when the pointer is over nothing.
func asset_ghost(name: String, at: Vector2) -> bool:
	var hit := _crosshair_hit(at)
	var bb := _asset_local_bounds(name)
	if hit.is_empty() or bb.size == Vector3.ZERO:
		asset_ghost_hide()
		return false
	if _asset_ghost == null or not is_instance_valid(_asset_ghost):
		_asset_ghost = MeshInstance3D.new()
		_asset_ghost.name = "AssetGhost"
		var mat := StandardMaterial3D.new()
		mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
		mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
		mat.albedo_color = Color(0.4, 0.8, 1.0, 0.25)
		_asset_ghost.material_override = mat
		_asset_ghost.mesh = BoxMesh.new()
		add_child(_asset_ghost)
	(_asset_ghost.mesh as BoxMesh).size = bb.size
	_asset_ghost.global_transform = drop_frame(hit["position"], hit.get("normal", Vector3.UP)) \
		* Transform3D(Basis.IDENTITY, bb.get_center())
	_asset_ghost.visible = true
	return true


func asset_ghost_hide() -> void:
	if _asset_ghost != null and is_instance_valid(_asset_ghost):
		_asset_ghost.visible = false


## An asset's bounds relative to its pivot (the base centre), from the library row.
func _asset_local_bounds(name: String) -> AABB:
	for r: Dictionary in (asset_library().rows() as Array):
		if String(r["name"]) == name:
			var b: Array = r.get("bounds", [])
			if b.size() == 6:
				var lo := Vector3(float(b[0]), float(b[1]), float(b[2]))
				var hi := Vector3(float(b[3]), float(b[4]), float(b[5]))
				var piv := Vector3((lo.x + hi.x) * 0.5, lo.y, (lo.z + hi.z) * 0.5)
				return AABB(lo - piv, hi - lo)
			return AABB(Vector3(-0.5, 0, -0.5), Vector3.ONE)   # personal-only: no catalogue bounds
	return AABB()


func _register_asset_browser() -> void:
	var dock := get_node_or_null("/root/BuildDock")
	if dock == null or (_asset_browser != null and is_instance_valid(_asset_browser)):
		return
	var ab := VBoxContainer.new()
	ab.set_script(load("res://scenes/game/asset_browser.gd"))
	ab.setup(self)
	dock.register("assets", "Assets", ab, true, dock.SIDE_BOTTOM, dock.WS_LEVEL)
	_asset_browser = ab


## A NEW COLLECTION'S NAME (user 2026-10-18: "when creating a new group i should be
## prompted for a name"), Blender's popup: make(name) runs on Enter, nothing on Esc, so the
## collection is made by ONE gesture or not at all. No popup (bare harnesses): "Collection".
func prompt_collection_name(title: String, make: Callable) -> void:
	if _q_menu == null or not _q_menu.has_method("open_name"):
		make.call("Collection")
		return
	var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
	_q_menu.open_name(title, "Collection", make, at)
	if get_viewport() != null:
		get_viewport().set_input_as_handled()


## The picker's rows for `mode` (move | link | remove): [[text, Callable]]. Nested
## collections are indented under their parents; move and link start with
## + New Collection, remove lists only collections the selection is linked into.
func collection_choices(mode: String) -> Array:
	_ensure_persistence()
	var t: Dictionary = _persist.collection_tree()
	var cols: Dictionary = t["cols"]
	var rows: Array = []
	if mode != "remove":
		rows.append(["+ New Collection", func() -> void:
			prompt_collection_name("New collection", func(n: String) -> void:
				var c := CollectionOps.create_empty(self, n)
				if mode == "move":
					CollectionOps.move_selection(self, c)
				else:
					CollectionOps.link_selection(self, c))])
	var in_sel := {}
	if mode == "remove":
		for m: String in _selection_member_names():
			for c: String in CollectionFold.collections_of(t, m):
				in_sel[c] = true
	var roots: Array = []
	for d: String in cols:
		if String(cols[d]["parent"]) == "":
			roots.append(d)
	roots.sort()
	var stack: Array = []
	for i in range(roots.size() - 1, -1, -1):
		stack.append([roots[i], 0])
	while not stack.is_empty():
		var top: Array = stack.pop_back()
		var d: String = top[0]
		if mode != "remove" or in_sel.has(d):
			var label := "    ".repeat(int(top[1])) + String(cols[d]["name"])
			match mode:
				"move":
					rows.append([label, func() -> void: CollectionOps.move_selection(self, d)])
				"link":
					rows.append([label, func() -> void: CollectionOps.link_selection(self, d)])
				"remove":
					rows.append([label, func() -> void: CollectionOps.unlink_selection(self, d)])
		var kids: Array = cols[d]["children"]
		for i in range(kids.size() - 1, -1, -1):
			stack.append([kids[i], int(top[1]) + 1])
	return rows


## The selection's members WITHOUT regrouping loose brushes (a loose brush is in no
## collection yet): for listing which collections it is in.
func _selection_member_names() -> Array:
	var out: Array = []
	for d: String in selected_objects():
		out.append("obj:" + d)
	if _sel_asset != "":
		out.append("a:" + _sel_asset)          # a placed asset (#120 L4)
		return out
	if _sel_shape != "":
		out.append("g:" + _sel_shape)
	for b: int in _sel_brushes:
		var g := String(level.get_brush_group(b))
		if g != "" and not out.has("g:" + g):
			out.append("g:" + g)
	return out


## Shift+G: the select-grouped menu's rows.
func grouped_sections() -> Array:
	return [["Select grouped (like the active brush)", [["builder.select_same_group"],
		["builder.select_same_material"], ["builder.select_same_texture"], ["builder.select_same_type"]]]]


## Every live brush that matches the ACTIVE brush by `kind`: group | material | texture | type.
func select_grouped(kind: String) -> int:
	if level == null or _sel_brush < 0:
		print("[MapDCLevel] select grouped: select a brush first (the active one is the example)")
		return 0
	var ref := _sel_brush
	var ref_d: Dictionary = level.get_brush(ref)
	var want: Variant = null
	match kind:
		"group":
			want = String(level.get_brush_group(ref))
		"material":
			want = int(ref_d.get("material_id", -1))
		"texture":
			want = _first_tex(ref_d)
		"type":
			var sd := shape_of_brush(ref)
			want = String((_persist.shape_state(sd) as Dictionary).get("type", "")) if sd != "" else ""
	if want == null or (want is String and String(want) == ""):
		print("[MapDCLevel] select grouped: the active brush has no %s" % kind)
		return 0
	var out := PackedInt32Array()
	for id: int in (level.get_live_brush_ids() as PackedInt32Array):
		var d: Dictionary = level.get_brush(id)
		if bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
			continue
		var v: Variant = null
		match kind:
			"group": v = String(level.get_brush_group(id))
			"material": v = int(d.get("material_id", -2))
			"texture": v = _first_tex(d)
			"type":
				var sd2 := shape_of_brush(id)
				v = String((_persist.shape_state(sd2) as Dictionary).get("type", "")) if sd2 != "" else ""
		if v == want:
			out.append(id)
	select_brushes(out)
	print("[MapDCLevel] select grouped (%s): %d brush(es)" % [kind, out.size()])
	return out.size()


static func _first_tex(d: Dictionary) -> String:
	for t: Variant in (d.get("face_tex", []) as Array):
		if t is Dictionary and String((t as Dictionary).get("tex", "")) != "":
			return String(t["tex"])
	return ""


## Ctrl+I: every live brush NOT selected now.
func select_invert() -> void:
	if level == null:
		return
	var had := {}
	for b: int in _sel_brushes:
		had[b] = true
	var out := PackedInt32Array()
	for id: int in (level.get_live_brush_ids() as PackedInt32Array):
		if not had.has(id):
			out.append(id)
	select_brushes(out)


## The add menu (Shift+A): one row per shape type -> the create tool on that shape.
func add_sections() -> Array:
	var rows: Array = []
	for s: String in ADD_SHAPES:
		rows.append(["builder.add_%s" % s])
	return [["Add shape", rows], ["Other", [["builder.add_curve"], ["builder.add_cable"], ["builder.cable_from_edges"],
		["builder.cable_bundle"], ["builder.carve"], ["builder.place_asset"]]]]


## Start the create tool already set to `shape_id`.
func start_create(shape_id: String) -> void:
	if _box == null:
		return
	var i: int = (_box.shape_ids() as Array).find("param:%s" % shape_id)
	if i < 0:
		print("[MapDCLevel] add: no shape type %s" % shape_id)
		return
	_box.set_tool_setting("shape", i)
	Keymap.run("builder.create_box")


## The U menu: every texture command, each with its own key shown.
func uv_sections() -> Array:
	return [["Texture", [["builder.texture_apply"], ["builder.texture_pick"]]],
		["Hotspot", [["builder.uv_hotspot"], ["builder.uv_reroll"], ["builder.uv_pin"]]],
		["Align", [["builder.uv_fit"], ["builder.uv_align"]]]]


## Z: Blender 2.79's wireframe toggle — the same switch as the global debug key.
func toggle_wireframe() -> void:
	RenderingServer.set_debug_generate_wireframes(true)
	var vp := get_viewport()
	vp.debug_draw = (Viewport.DEBUG_DRAW_DISABLED if vp.debug_draw == Viewport.DEBUG_DRAW_WIREFRAME
		else Viewport.DEBUG_DRAW_WIREFRAME)
	print("[MapDCLevel] wireframe %s" % ("on" if vp.debug_draw == Viewport.DEBUG_DRAW_WIREFRAME else "off"))


## ---- BOX SELECT (B) — drag a rectangle; brushes whose centre falls in it. Shift adds.
var _box_sel := false
var _box_from := Vector2.ZERO
var _box_dragging := false
var _box_layer: CanvasLayer = null
var _box_rect: ColorRect = null


func begin_box_select() -> void:
	_box_sel = true
	_box_dragging = false
	print("[MapDCLevel] box select: drag a rectangle (Shift adds, Esc / RMB cancels)")


func is_box_selecting() -> bool:
	return _box_sel


func _box_show(r: Rect2) -> void:
	if _box_layer == null:
		_box_layer = CanvasLayer.new()
		_box_layer.layer = 90
		add_child(_box_layer)
		_box_rect = ColorRect.new()
		_box_rect.color = Color(1.0, 0.7, 0.2, 0.15)
		_box_rect.mouse_filter = Control.MOUSE_FILTER_IGNORE
		_box_layer.add_child(_box_rect)
	_box_rect.visible = r.size != Vector2.ZERO
	_box_rect.position = r.position
	_box_rect.size = r.size


## Handles the box-select drag. True when it took the event.
func _box_select_input(event: InputEvent) -> bool:
	if not _box_sel:
		return false
	if event is InputEventMouseButton:
		var mb := event as InputEventMouseButton
		if mb.button_index == MOUSE_BUTTON_RIGHT and mb.pressed:
			_box_sel = false
			_box_show(Rect2())
			return true
		if mb.button_index != MOUSE_BUTTON_LEFT:
			return false
		if mb.pressed:
			_box_from = mb.position
			_box_dragging = true
			_box_show(Rect2(_box_from, Vector2.ZERO))
		elif _box_dragging:
			box_select(Rect2(_box_from, Vector2.ZERO).expand(mb.position), mb.shift_pressed)
			_box_sel = false
			_box_dragging = false
			_box_show(Rect2())
		return true
	if event is InputEventMouseMotion and _box_dragging:
		_box_show(Rect2(_box_from, Vector2.ZERO).expand((event as InputEventMouseMotion).position))
		return true
	return false


## ---- CIRCLE SELECT (C) — Blender 2.79's view3d.select_circle (#109 step 6) ----------
## A ring follows the pointer. LMB drag PAINTS brushes into the selection, Shift+LMB
## paints them out; the wheel or [ ] resizes the ring; RMB / Esc / Enter leaves, the
## selection kept. A brush counts when its centre projects inside the ring (box
## select's rule).
var _circ_sel := false
var _circ_radius := 40.0              # screen pixels
var _circ_pos := Vector2.ZERO
var _circ_painting := false
var _circ_sub := false
var _circ_layer: CanvasLayer = null
var _circ_ui: Control = null
const CIRC_MIN := 8.0
const CIRC_MAX := 600.0
## Letters by character, [ ] by position (keymap step 5) — as LevelOperator does.
const KeyRules := preload("res://autoloads/key_rules.gd")


func begin_circle_select() -> void:
	_circ_sel = true
	_circ_painting = false
	var vp := get_viewport()
	if vp != null:
		_circ_pos = vp.get_mouse_position()
	_circ_show()
	print("[MapDCLevel] circle select: LMB paints in, Shift+LMB paints out, wheel / [ ] resize, RMB / Esc / Enter ends")


func is_circle_selecting() -> bool:
	return _circ_sel


func end_circle_select() -> void:
	_circ_sel = false
	_circ_painting = false
	_circ_show()


func _circ_show() -> void:
	if _circ_layer == null:
		_circ_layer = CanvasLayer.new()
		_circ_layer.layer = 90
		add_child(_circ_layer)
		_circ_ui = Control.new()
		_circ_ui.mouse_filter = Control.MOUSE_FILTER_IGNORE
		_circ_ui.set_anchors_preset(Control.PRESET_FULL_RECT)
		_circ_layer.add_child(_circ_ui)
		_circ_ui.draw.connect(_circ_draw)
	_circ_ui.visible = _circ_sel
	_circ_ui.queue_redraw()


func _circ_draw() -> void:
	if not _circ_sel:
		return
	var col := Color(1.0, 0.45, 0.35, 0.9) if _circ_sub else Color(1.0, 0.7, 0.2, 0.9)
	_circ_ui.draw_arc(_circ_pos, _circ_radius, 0.0, TAU, 64, col, 1.5, true)


## Handles circle mode's mouse. True when it took the event.
func _circle_select_input(event: InputEvent) -> bool:
	if not _circ_sel:
		return false
	if event is InputEventMouseMotion:
		_circ_pos = (event as InputEventMouseMotion).position
		if _circ_painting:
			circle_select_at(_circ_pos, _circ_radius, _circ_sub)
		_circ_show()
		return true
	if event is InputEventMouseButton:
		var mb := event as InputEventMouseButton
		_circ_pos = mb.position
		match mb.button_index:
			MOUSE_BUTTON_RIGHT:
				if mb.pressed:
					end_circle_select()
				return true
			MOUSE_BUTTON_WHEEL_UP, MOUSE_BUTTON_WHEEL_DOWN:
				if mb.pressed:
					_circ_radius = clampf(_circ_radius * (1.15 if mb.button_index == MOUSE_BUTTON_WHEEL_UP else 1.0 / 1.15),
						CIRC_MIN, CIRC_MAX)
					_circ_show()
				return true
			MOUSE_BUTTON_LEFT:
				_circ_painting = mb.pressed
				_circ_sub = mb.shift_pressed
				if mb.pressed:
					circle_select_at(_circ_pos, _circ_radius, _circ_sub)
				_circ_show()
				return true
	return false


## Paint the live brushes whose centre projects within `radius` of `at` (screen pixels)
## into the selection, or out of it when `sub`. Returns the selection size.
func circle_select_at(at: Vector2, radius: float, sub: bool) -> int:
	var cam := get_viewport().get_camera_3d()
	if cam == null or level == null:
		return 0
	var out := PackedInt32Array(_sel_brushes)
	var xf: Transform3D = (level as Node3D).global_transform
	var changed := false
	for id: int in (level.get_live_brush_ids() as PackedInt32Array):
		var d: Dictionary = level.get_brush(id)
		if not d.has("aabb_min") or bool(d.get("clearance", false)):
			continue
		var c: Vector3 = xf * (((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5)
		if cam.is_position_behind(c) or cam.unproject_position(c).distance_to(at) > radius:
			continue
		var i := out.find(id)
		if sub and i >= 0:
			out.remove_at(i)
			changed = true
		elif not sub and i < 0:
			out.append(id)
			changed = true
	if changed:
		select_brushes(out)
	return out.size()


## Select the live brushes whose centre projects into `r` (screen pixels).
func box_select(r: Rect2, add: bool) -> int:
	var cam := get_viewport().get_camera_3d()
	if cam == null or level == null:
		return 0
	var out := PackedInt32Array(_sel_brushes) if add else PackedInt32Array()
	var xf: Transform3D = (level as Node3D).global_transform
	for id: int in (level.get_live_brush_ids() as PackedInt32Array):
		var d: Dictionary = level.get_brush(id)
		if not d.has("aabb_min") or bool(d.get("clearance", false)) or out.has(id):
			continue
		var c: Vector3 = xf * (((d["aabb_min"] as Vector3) + (d["aabb_max"] as Vector3)) * 0.5)
		if cam.is_position_behind(c):
			continue
		if r.has_point(cam.unproject_position(c)):
			out.append(id)
	select_brushes(out)
	print("[MapDCLevel] box select: %d brush(es)" % out.size())
	return out.size()


func _q_make_real() -> void:
	if _sel_asset != "":
		make_real_asset(_sel_asset)           # a placed asset (#120 L4 §15.3)
		return
	var s := stack_subject()
	if s != "":
		make_real(s)


## E ON A SHAPE PIECE (#109, user 2026-10-13, option A): the pieces are read-only — the
## fold regenerates them from the shape's params, so a pushed face is an edit the
## generator cannot reproduce. Extrude asks instead of acting: Make Real, then extrude
## the face still under the aim; or Cancel, which writes nothing.
var _pending_extrude_shape := ""


func prompt_shape_extrude(dot: String) -> void:
	_pending_extrude_shape = dot
	if _q_menu == null:
		return
	var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
	_q_menu.open_sections([["This is a shape: its faces are read-only",
		[["builder.extrude_make_real"], ["builder.prompt_cancel"]]]], at)
	print("[MapDCLevel] extrude: %s is a parametric shape — Make Real, or Cancel" % dot)


func _q_extrude_make_real() -> void:
	var dot := _pending_extrude_shape
	_pending_extrude_shape = ""
	if dot == "" or make_real(dot) <= 0:
		return
	# The aim has not moved: the face under it is now a plain brush's.
	if _ensure_ops().activate(_extrude):
		print("[MapDCLevel] extrude: [ ] to push the face, Enter to extrude, Esc to cancel")


func _q_prompt_cancel() -> void:
	if _pending_extrude_shape != "":
		print("[MapDCLevel] extrude cancelled — %s is unchanged" % _pending_extrude_shape)
	_pending_extrude_shape = ""
	if _pending_part_shape != "":
		print("[MapDCLevel] edit cancelled — %s is unchanged" % _pending_part_shape)
	_pending_part_shape = ""
	_pending_part_edit = Callable()


## A SHAPE'S PARTS ARE SELECTABLE, READ-ONLY UNTIL MADE REAL (#109, user 2026-10-13:
## "select, prompt on edit"). Every edit of a vertex / edge / face (G, R, the [ ] nudge,
## the knife) asks here first: on a plain brush it goes ahead; on a shape's piece it asks
## "Make Real, then go on / Cancel" and remembers what was asked for.
var _pending_part_shape := ""
var _pending_part_edit := Callable()


## True: go ahead. False: `brush` (default the selected one) is a shape's piece — the
## prompt is open, and Make Real will run `resume` on the same part of the plain brush.
func guard_shape_part(what: String, resume: Callable, brush: int = -2) -> bool:
	var b := _sel_brush if brush == -2 else brush
	# A PLACED ASSET's part (#120 L4): read-only too. Make Real first (no "then go on":
	# its parts come back under new names in a new collection), or Cancel.
	var pa := placement_of_brush(b) if b >= 0 else ""
	if pa != "":
		if _q_menu != null:
			var pat := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
			_q_menu.open_sections([["This is a placed asset: its parts are read-only",
				[["builder.make_real"], ["builder.prompt_cancel"]]]], pat)
		_sel_asset = pa
		print("[MapDCLevel] %s: %s is a placed asset — Make Real it first, or Cancel" % [what, pa])
		return false
	var sd := shape_of_brush(b) if b >= 0 else ""
	if sd == "":
		return true
	_pending_part_shape = sd
	_pending_part_edit = resume
	if _q_menu != null:
		var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
		_q_menu.open_sections([["This is a shape: its parts are read-only",
			[["builder.make_real_then"], ["builder.prompt_cancel"]]]], at)
	print("[MapDCLevel] %s: %s is a parametric shape — Make Real, then go on; or Cancel" % [what, sd])
	return false


## MAKE REAL, THEN GO ON: the shape's pieces become plain brushes (new ids, new plane
## dots, the planes in the SAME order), so the selected part is carried across by PLANE
## INDEX onto the brush its piece became — points and edge ends are world-space and the
## geometry is unchanged — and then the edit that was asked for runs.
func _q_make_real_then() -> void:
	var dot := _pending_part_shape
	var resume := _pending_part_edit
	_pending_part_shape = ""
	_pending_part_edit = Callable()
	if dot == "":
		return
	var kind := _sel_kind
	var point := _sel_point
	var ea := _sel_edge_a
	var eb := _sel_edge_b
	var old := _sel_brush
	var idx: Array[int] = []
	for k: String in _sel_keys:
		idx.append(level.face_by_key(old, k))
	var faces: Array = []                  # the cross-brush face set: [key, old id, plane index]
	for fk: Dictionary in _sel_faces:
		var ob: int = level.brush_by_key(String(fk["b"]))
		faces.append([String(fk["b"]), ob, level.face_by_key(ob, String(fk["p"])) if ob >= 0 else -1])
	var edges: Array = []                  # the edge set (§14.4): [key, old id, plane index, plane index]
	for ek: Dictionary in _sel_edges:
		var oe: int = level.brush_by_key(String(ek["b"]))
		var pk: Array = ek["p"]
		edges.append([String(ek["b"]), oe, level.face_by_key(oe, String(pk[0])) if oe >= 0 else -1,
			level.face_by_key(oe, String(pk[1])) if oe >= 0 else -1])
	if make_real(dot) <= 0:
		return
	var nb: int = level.brush_by_key(String(last_real.get(old, ""))) if last_real.has(old) else -1
	if nb >= 0 and kind != "brush":
		var keys: PackedStringArray = level.get_brush(nb).get("plane_keys", PackedStringArray())
		var sel := PackedStringArray()
		for i: int in idx:
			if i >= 0 and i < keys.size():
				sel.append(keys[i])
		_sel_brushes.clear()
		_sel_brushes.append(nb)
		_sel_brush = nb
		_sel_kind = kind
		_sel_keys = sel
		_sel_point = point
		_sel_edge_a = ea
		_sel_edge_b = eb
		_sel_faces.clear()
		for f: Array in faces:
			# A piece of the shape: its new brush. Any other brush: unchanged.
			var key := String(last_real[int(f[1])]) if last_real.has(int(f[1])) else String(f[0])
			var b2: int = level.brush_by_key(key)
			var k2: PackedStringArray = level.get_brush(b2).get("plane_keys", PackedStringArray()) if b2 >= 0 else PackedStringArray()
			if b2 >= 0 and int(f[2]) >= 0 and int(f[2]) < k2.size():
				_sel_faces.append({"b": key, "p": k2[int(f[2])]})
		_sel_edges.clear()
		for e: Array in edges:
			var ekey := String(last_real[int(e[1])]) if last_real.has(int(e[1])) else String(e[0])
			var b3: int = level.brush_by_key(ekey)
			var k3: PackedStringArray = level.get_brush(b3).get("plane_keys", PackedStringArray()) if b3 >= 0 else PackedStringArray()
			if b3 >= 0 and int(e[2]) >= 0 and int(e[3]) >= 0 and int(e[2]) < k3.size() and int(e[3]) < k3.size():
				var pr: Array = [String(k3[int(e[2])]), String(k3[int(e[3])])]
				pr.sort()
				_sel_edges.append({"b": ekey, "p": pr})
		_update_face_highlight()
		_update_element_highlight()
	if resume.is_valid():
		resume.call()


## Clear the cuts: those MADE WITH the subject when it is a cutter, else those ON it.
## One gesture; Ctrl+Z brings them all back.
func clear_cuts(subject: String = "") -> int:
	if subject == "":
		subject = stack_subject()
	var ops: Array = cuts_by(subject)
	var made_with := not ops.is_empty()
	if ops.is_empty():
		ops = op_stack(subject)
	var seen := {}
	_persist.begin_gesture("Clear cuts")
	for o: Dictionary in ops:
		if not seen.has(o["dot"]):
			seen[o["dot"]] = true
			_persist.record("op.remove", {"target": o["dot"]})
	_persist.end_gesture()
	if not seen.is_empty():
		_persist.refold()
		_refresh_brush_view()
	print("[csg] cleared %d cut(s) %s %s" % [seen.size(), "made with" if made_with else "on", subject])
	return seen.size()


## A cutter's ops all become `kind` — one gesture.
func set_cutter_kind(cutter: String, kind: String) -> int:
	var seen := {}
	_persist.begin_gesture("Cut: %s" % kind)
	for o: Dictionary in cuts_by(cutter):
		if not seen.has(o["dot"]) and String(o["kind"]) != kind:
			seen[o["dot"]] = true
			_persist.record("op.set", {"target": o["dot"], "op": kind})
	_persist.end_gesture()
	if not seen.is_empty():
		_persist.refold()
		_refresh_brush_view()
	return seen.size()


## Go from a cutter to what it cuts (the first target).
func select_cut_target(cutter: String = "") -> void:
	if cutter == "":
		cutter = stack_subject()
	var cuts: Array = cuts_by(cutter)
	if cuts.is_empty():
		return
	var t := String(cuts[0]["target"])
	if (_persist.live_shapes() as Dictionary).has(t):
		select_shape(t)
	else:
		select_brushes(level.get_group_brush_ids(t))


## H / Shift+H / Alt+H — through the Outliner's rows, so its eye shows the same state.
## OBJECTS too (#120 F1, outliner audit bug 7: it returned on an empty brush selection),
## by their rows, so the eye shows it.
func hide_selection() -> int:
	if _outliner == null or (_sel_brushes.is_empty() and _sel_objects.is_empty() and _sel_curves.is_empty()):
		return 0
	# THE UNION (§17.10): every kind held is hidden, by its rows.
	var n := 0
	if not _sel_curves.is_empty():
		n += int(_outliner.hide_curves(selected_curves()))
	if not _sel_objects.is_empty():
		n += int(_outliner.hide_objects(selected_objects()))
	if not _sel_brushes.is_empty():
		n += int(_outliner.hide_brushes(Array(_sel_brushes)))
	_clear_face_selection()              # Blender deselects what it hides
	_refresh_brush_view()
	print("[MapDCLevel] hid %d row(s) — Alt+H shows everything again" % n)
	return n


func hide_unselected() -> int:
	if _outliner == null or (_sel_brushes.is_empty() and _sel_objects.is_empty()):
		return 0
	var n: int = _outliner.hide_others(Array(_sel_brushes), selected_objects())
	_refresh_brush_view()
	print("[MapDCLevel] hid %d other row(s) — Alt+H shows everything again" % n)
	return n


func unhide_all() -> int:
	if _outliner == null:
		return 0
	var n: int = _outliner.unhide_all()
	_refresh_brush_view()
	print("[MapDCLevel] unhid %d row(s)" % n)
	return n


## Any movement key held OTHER than move_back (the S of a Shift+S press is move_back).
func _moving_except_back() -> bool:
	for a: String in ["move_forward", "move_left", "move_right", "jump", "crouch"]:
		if InputMap.has_action(a) and Input.is_action_pressed(a):
			return true
	return false


## For the status bar's right side: what only the map knows.
func _status_info() -> Dictionary:
	return {"grid": "%s · snap %s" % [fmt_units(base_grid_units()), "on" if snap_enabled else "off"],
		"sel": _sel_brushes.size()}


## The grid dropdown's entry point (keys use - / = in the input handler).
func set_grid_index(i: int) -> void:
	_grid_idx = clampi(i, 0, GRID_UNITS.size() - 1)
	print("[MapDCLevel] grid %s (%.3f m)" % [fmt_units(base_grid_units()), base_grid_units() * WORLD_SCALE])


func set_current_tex(id: String) -> void:
	current_tex = id
	if _tex_strip != null and is_instance_valid(_tex_strip):
		_tex_strip.refresh_current()


## Only on the pack-textured empty level: on the .MAP import path faces index
## START.MAP's own table, and a pack id written there would draw the missing layer.
func _register_texture_strip() -> void:
	var dock := get_node_or_null("/root/BuildDock")
	if dock == null:
		return
	var strip := VBoxContainer.new()
	strip.set_script(load("res://scenes/game/texture_strip.gd"))
	strip.setup(self)
	dock.register("textures", "Textures", strip, true, dock.SIDE_BOTTOM, dock.WS_LEVEL)
	_tex_strip = strip
	# UV controls for the selection (docs/uv-controls-design.md §4) — same maps as the strip.
	var face := VBoxContainer.new()
	face.set_script(load("res://scenes/game/face_panel.gd"))
	face.setup(self)
	dock.register("face", "Face", face, true, dock.SIDE_RIGHT, dock.WS_LEVEL)
	_face_panel = face


## U — the CURRENT texture onto the selection. A face (or edge / vertex) selection
## textures the selected faces of the active brush; a brush selection textures every
## face of every selected brush.
##
## ONLY THE TEXTURE CHANGES (mask TEX): offsets, scale, rotation and a lock-frozen
## explicit map are kept, so retexturing an aligned wall does not misalign it. A
## face that had NO texture also becomes paraxial (mask TEX|MODE), or it would
## carry an id and still draw nothing. One face.tex record per brush per mask —
## applied locally first through DCLevel.set_face_tex, which merges exactly as the
## fold does, because the author never refolds its own echo.
func apply_texture_to_selection() -> int:
	if _sel_brushes.is_empty():
		print("[texture] nothing selected — click a brush or face first (B / left click)")
		return 0
	# A PARAMETRIC SHAPE is textured as a whole (§13.3): one shape.set {tex}. A face.tex
	# on a piece would be lost — every regeneration names the pieces afresh.
	if _sel_asset != "" or placement_of_brush(_sel_brush) != "":
		print("[texture] a placed asset's faces are read-only (#120 L4) — Make Real it to texture them")
		return 0
	if _sel_shape != "":
		return set_shape_tex(_sel_shape, current_face_tex())
	var tex_id: String = current_face_tex()["tex"]
	var done := 0
	for t: Array in _texture_targets():
		var b: int = t[0]
		var keys: PackedStringArray = t[1]
		var fts: Array = level.get_brush(b).get("face_tex", [])
		var textured: Array = []
		var bare: Array = []
		for k: String in keys:
			var f: int = level.face_by_key(b, k)
			if f < 0:
				continue
			var mode: int = int((fts[f] as Dictionary).get("mode", 0)) if f < fts.size() else 0
			(bare if mode == 0 else textured).append(k)
		var bkey: String = level.get_brush_key(b)
		for job: Array in [[textured, 1, {"tex": tex_id}], [bare, 3, {"tex": tex_id, "mode": 1}]]:
			var ks: Array = job[0]
			if ks.is_empty():
				continue
			for k: String in ks:
				level.set_face_tex(b, level.face_by_key(b, k), job[2], job[1])
			_persist.record("face.tex", {"brush_key": bkey, "face_keys": ks,
				"tex": job[2], "mask": job[1]})
			done += ks.size()
	if done > 0:
		level.rebuild()
		_note_recent_texture(tex_id)
	print("[texture] %s on %d face(s)" % [tex_id, done])
	return done


## [[brush id, face keys], ...] that texture ops act on: the selected faces of the
## active brush (face / edge / vertex selection), or every face of every selected brush.
func _texture_targets() -> Array:
	var targets: Array = []
	if _sel_kind != "brush" and _sel_brush >= 0 and not _sel_keys.is_empty():
		targets.append([_sel_brush, _sel_keys])
	else:
		for b: int in _sel_brushes:
			targets.append([b, level.get_brush(b).get("plane_keys", PackedStringArray())])
	return targets


## face.tex field masks — libdc DCTexMask.
const TEXMASK_MODE := 2
const TEXMASK_OFFSET := 4
const TEXMASK_ROT := 8
const TEXMASK_SCALE := 16
const TEXMASK_EXPLICIT := 32
const TEXMASK_P2 := 64          # rect, seed, hflags (hotspot)
## Which fields each alignment op writes on a rule-mode face. On an EXPLICIT face
## every op writes the explicit axes instead (mask EXPLICIT).
const _UV_OP_MASK := {"fit": TEXMASK_SCALE, "justify": TEXMASK_OFFSET, "align": TEXMASK_ROT,
	"nudge": TEXMASK_OFFSET | TEXMASK_ROT | TEXMASK_SCALE,
	"nudge_screen": TEXMASK_OFFSET, "justify_screen": TEXMASK_OFFSET}


## The camera's screen axes in world space, for the camera-relative ops. No camera
## (a headless gate) -> world X / Y, which is what a camera looking down -Z sees.
func camera_axes() -> Dictionary:
	var cam := get_viewport().get_camera_3d() if is_inside_tree() else null
	if cam == null:
		return {"right": Vector3.RIGHT, "up": Vector3.UP}
	var b := cam.global_transform.basis
	return {"right": b.x.normalized(), "up": b.y.normalized()}


## UV CONTROLS (docs/uv-controls-design.md) on the selection — same targets as U.
##   fit {axis, policy, trim} · justify {axis, sign, policy} · align {policy}
##   nudge {du, dv (texture metres), drot (deg), mul_u, mul_v} · mode {mode 1|2|3}
##   reset {}
## The maths is libdc's (DCLevel.face_tex_compute, author-side only: it reads the
## face polygon and the pack's tile size). Each face gets the op's fields ONLY (the
## record mask), applied locally first like U-apply, then ONE face.tex record per
## brush per distinct result. Untextured faces are skipped (apply a texture first).
## Returns the number of faces changed.
func uv_op(op: String, args: Dictionary = {}) -> int:
	if _sel_brushes.is_empty() or level == null:
		return 0
	var done := 0
	var shape_dots := {}
	for t: Array in _texture_targets():
		var b: int = t[0]
		# HOTSPOT ON A LIVE SHAPE goes on the SHAPE (its tex), not on its pieces: a piece is
		# named "<author>/<i>@<ver>:<seq>" and every shape edit renames it, so a face.tex
		# record on one was dropped by the next edit (user 2026-10-12: "fix hotspot on
		# shapes so it survives edits"). The fold copies the shape's tex to every solid face.
		if op.begins_with("hotspot") and shape_of_brush(b) != "":
			shape_dots[shape_of_brush(b)] = true
			continue
		var fts: Array = level.get_brush(b).get("face_tex", [])
		var groups := {}     # "mask|value" -> [mask, masked tex, face keys]
		for k: String in (t[1] as PackedStringArray):
			var f: int = level.face_by_key(b, k)
			if f < 0 or f >= fts.size():
				continue
			var cur: Dictionary = fts[f]
			var mode := int(cur.get("mode", 0))
			if mode == 0 or String(cur.get("tex", "")) == "":
				continue
			var res := {}
			var mask := 0
			if op == "reset":
				res = {"mode": 1, "off_u": 0.0, "off_v": 0.0, "rot": 0.0, "scale_u": 1.0, "scale_v": 1.0}
				mask = TEXMASK_MODE | TEXMASK_OFFSET | TEXMASK_ROT | TEXMASK_SCALE
			elif op == "hotspot":
				# Fit to the best rect of the face's trim sheet (auto rect, seed kept). Only
				# textures with rects can — anything else would draw the missing layer.
				if not (TexturePacks.entry(String(cur["tex"])).get("rects") is Array):
					continue
				res = {"mode": 4, "rect": -1, "seed": int(cur.get("seed", 0)), "hflags": 0}
				mask = TEXMASK_MODE | TEXMASK_P2
			elif op == "hotspot_reroll":
				if mode != 4:
					continue
				# The rect rides along UNCHANGED: P2 writes rect + seed + hflags together, and
				# writing -1 here silently unpinned a pinned face.
				res = {"rect": int(cur.get("rect", -1)), "seed": int(cur.get("seed", 0)) + 1,
					"hflags": int(cur.get("hflags", 0))}
				mask = TEXMASK_P2
			elif op == "hotspot_pin":
				if mode != 4:
					continue
				res = level.face_tex_compute(b, f, "hotspot_pin", {})
				mask = TEXMASK_P2
			elif op == "mode":
				var m := int(args.get("mode", 1))
				if m == mode:
					continue
				if m == 2:   # -> explicit: freeze the current look
					res = level.face_tex_compute(b, f, "freeze", {})
					mask = TEXMASK_MODE | TEXMASK_EXPLICIT
				else:        # -> a rule: its stored offset / rot / scale apply again
					res = {"mode": m}
					mask = TEXMASK_MODE
			else:
				if not _UV_OP_MASK.has(op):
					return done
				var a := args.duplicate()
				var e: Dictionary = TexturePacks.entry(String(cur["tex"]))
				a["tile_u"] = float(e.get("tile_u_m", 1.0))
				a["tile_v"] = float(e.get("tile_v_m", 1.0))
				res = level.face_tex_compute(b, f, op, a)
				mask = TEXMASK_EXPLICIT if mode == 2 else int(_UV_OP_MASK[op])
			if res.is_empty():
				continue
			var masked := _masked_tex(res, mask)
			level.set_face_tex(b, f, masked, mask)
			var gk := "%d|%s" % [mask, var_to_str(masked)]
			if not groups.has(gk):
				groups[gk] = [mask, masked, []]
			(groups[gk][2] as Array).append(k)
			done += 1
		var bkey: String = level.get_brush_key(b)
		for g: Array in groups.values():
			_persist.record("face.tex", {"brush_key": bkey, "face_keys": g[2], "tex": g[1], "mask": g[0]})
	if done > 0:
		level.rebuild()
	if not shape_dots.is_empty():
		done += _shape_hotspot(op, shape_dots.keys())
	print("[texture] %s on %d face(s)" % [op, done])
	return done


## Hotspot on whole parametric shapes: ONE shape.set {tex} each (mode 4, auto rect), so it
## survives every later edit of the shape. Re-roll = the shape's seed + 1 (each face still
## breaks its own near-ties from seed + its face hash, which ignores the piece's @<ver> —
## dc_face_hash). Pin is per-face and a shape has ONE tex, so pinning would force every
## face onto one rect: refused, with the way to do it. Returns the solid faces covered.
func _shape_hotspot(op: String, dots: Array) -> int:
	var n := 0
	var wrote := false
	for d: Variant in dots:
		var dot := String(d)
		var tex: Dictionary = ((_persist.shape_state(dot) as Dictionary).get("tex", {}) as Dictionary).duplicate()
		var id := String(tex.get("tex", ""))
		if id == "" or not (TexturePacks.entry(id).get("rects") is Array):
			print("[texture] shape %s has no trim-sheet texture — apply one (e.g. composed/industrial) first" % dot)
			continue
		match op:
			"hotspot":
				tex["mode"] = 4
				tex["rect"] = -1
				tex["seed"] = int(tex.get("seed", 0))
				tex["hflags"] = 0
			"hotspot_reroll":
				if int(tex.get("mode", 0)) != 4:
					continue
				tex["seed"] = int(tex.get("seed", 0)) + 1
			_:
				print("[texture] Pin on a whole shape would put every face on ONE rect — Make Real, then pin faces")
				continue
		if _persist.record("shape.set", {"target": dot, "params": {}, "tex": tex}) != "":
			wrote = true
	if wrote:
		_persist.refold()
		_reselect_shape()
		for d2: Variant in dots:
			for b: int in (level.get_group_brush_ids(String(d2)) as PackedInt32Array):
				var bd: Dictionary = level.get_brush(b)
				if not bool(bd.get("clearance", false)) and not bool(bd.get("cutter", false)):
					n += (bd.get("planes", PackedFloat32Array()) as PackedFloat32Array).size() / 4
	return n


## The `face_sel` keys (keymap.gd). True when the event was one of them.
func _uv_key(event: InputEvent) -> bool:
	var step: float = _grid_step()       # one EFFECTIVE grid step, metres (user: tied to the grid)
	# ARROWS ARE CAMERA-RELATIVE (TrenchBroom): the texture moves the way the key points
	# on screen, whatever the face's u / v happen to be.
	var arrows := {"builder.uv_left": [-1, 0], "builder.uv_right": [1, 0],
		"builder.uv_up": [0, 1], "builder.uv_down": [0, -1]}
	for action: String in arrows:
		if Keymap.tapped(event, action):
			var a := camera_axes()
			a["dx"] = arrows[action][0]
			a["dy"] = arrows[action][1]
			a["step"] = step
			uv_op("nudge_screen", a)
			_last_uv_key = action
			return true
	for pair: Array in [["builder.uv_hotspot", "hotspot"], ["builder.uv_reroll", "hotspot_reroll"],
			["builder.uv_pin", "hotspot_pin"]]:
		if Keymap.tapped(event, String(pair[0])):
			uv_op(String(pair[1]))
			_last_uv_key = String(pair[0])
			return true
	var nudges := {
		"builder.uv_rot_ccw": {"drot": 15.0}, "builder.uv_rot_cw": {"drot": -15.0},
		"builder.uv_rot_ccw_fine": {"drot": 1.0}, "builder.uv_rot_cw_fine": {"drot": -1.0},
	}
	for action: String in nudges:
		if Keymap.tapped(event, action):
			uv_op("nudge", nudges[action])
			_last_uv_key = action
			return true
	# ALWAYS NEXT (Shift: PREV), as TrenchBroom's buttons do. Stateless: libdc only
	# advances when the face already sits exactly on a candidate, so the first press
	# on an unfitted / unaligned face still lands on the best one.
	for pair: Array in [["builder.uv_fit", "fit", 1], ["builder.uv_fit_prev", "fit", 2],
			["builder.uv_align", "align", 1], ["builder.uv_align_prev", "align", 2]]:
		if not Keymap.tapped(event, String(pair[0])):
			continue
		if String(pair[1]) == "fit":
			uv_op("fit", {"axis": 0, "policy": pair[2]})
			uv_op("fit", {"axis": 1, "policy": pair[2]})
		else:
			uv_op("align", {"policy": pair[2]})
		_last_uv_key = String(pair[0])
		return true
	return false


## Only the fields a mask covers — records stay small and say exactly what changed.
static func _masked_tex(t: Dictionary, mask: int) -> Dictionary:
	var out := {}
	var keys: Array = []
	if mask & TEXMASK_MODE:
		keys.append("mode")
	if mask & TEXMASK_OFFSET:
		keys.append_array(["off_u", "off_v"])
	if mask & TEXMASK_ROT:
		keys.append("rot")
	if mask & TEXMASK_SCALE:
		keys.append_array(["scale_u", "scale_v"])
	if mask & TEXMASK_EXPLICIT:
		keys.append_array(["U", "V", "u0", "v0"])
	if mask & TEXMASK_P2:
		keys.append_array(["rect", "seed", "hflags"])
	for k: String in keys:
		if t.has(k):
			out[k] = t[k]
	return out


## Y — make the aimed face's texture the current one. `hit` is for gates; in play it
## is the crosshair (or the pointer in cursor mode).
func eyedrop_texture(hit: Dictionary = {}) -> String:
	if hit.is_empty():
		hit = _crosshair_hit()
	if hit.is_empty():
		return ""
	var b: int = _brush_from_hit(hit)
	if b < 0:
		return ""
	var f: int = level.pick_face(b, hit["position"])
	var fts: Array = level.get_brush(b).get("face_tex", [])
	if f < 0 or f >= fts.size():
		return ""
	var id := String((fts[f] as Dictionary).get("tex", ""))
	if id == "":
		print("[texture] that face has no texture")
		return ""
	set_current_tex(id)
	print("[texture] picked %s" % id)
	return id


func _note_recent_texture(id: String) -> void:
	if _tex_strip != null and is_instance_valid(_tex_strip):
		_tex_strip.refresh_current()


func _on_texture_setting(key: String, _value: Variant) -> void:
	if key == "texture_res" and _packs != null:
		_packs.set_res(int(Settings.resolved_value("texture_res")))
	elif key == "surface_detail" and _packs != null:
		_packs.set_detail(int(Settings.resolved_value("surface_detail")))


## The three files load_pbr actually reads per material, lowercased.
##
## ⛔ NOT _norm.tga. wad_loader.gd:171 GENERATES the normal from _bump with
## DarkPlaces' math on purpose — hemebond's baked _norm.tga uses Z=1 and gives
## near-sideways normals (median ~88 deg) where DP's Z=382 gives gentle relief.
## Publishing the 343 _norm.tga files in build_out would ship bytes nothing reads.
const PBR_SUFFIXES: Array[String] = ["_gloss.jpg", "_glow.jpg", "_bump.jpg"]

## Where fetched PBR files are materialised under the names load_pbr expects.
const PBR_LOCAL_DIR := "user://assets/pbr"


## Retained ONLY so a late-arriving WAD can re-texture. Nothing else reads it;
## if the textures are already local at import time this stays unused.
var _importer_for_retexture: QuakeMapImporter = null
var _retexture_hooked := false
var _retexture_pending := false

## How long to gather asset_ready arrivals before one rebuild. Long enough that a
## burst of PBR files coalesces, short enough to be invisible on a join.
const RETEXTURE_DEBOUNCE_S := 0.75


## The WAD and palette, resolved through the room rather than assumed local.
##
## Returns "" for either when the bytes are not here YET — not an error and not
## permanent: _hook_retexture() re-runs the material once they land.
func _wad_paths() -> Array:
	return [asset_path("wad", "base.wad", WAD_PATH),
			asset_path("palette", "palette.lmp", PAL_PATH)]


## Re-apply the material when a declared asset arrives.
##
## ⛔ RE-RUNS _apply_textures AND NOTHING ELSE. _spawn_sky_area_lights SPAWNS
## NODES; calling it twice is how the duplicate spawners in hyper-games #9 item 9
## happened. _apply_textures is safe to repeat because it builds a fresh
## ShaderMaterial and ends at `mi.material_override = mat` — it adds nothing to
## the tree.
func _hook_retexture() -> void:
	if _retexture_hooked:
		return
	var sync := get_node_or_null(^"/root/AssetSync")
	if sync == null or not sync.has_signal("asset_ready"):
		return
	sync.asset_ready.connect(_on_asset_ready)
	_retexture_hooked = true


func _on_asset_ready(kind: String, _name: String, _path: String) -> void:
	if kind != "wad" and kind != "palette" and kind != "pbr":
		return
	if _importer_for_retexture == null:
		return
	var wp := _wad_paths()
	if String(wp[0]) == "" or String(wp[1]) == "":
		return   # the other half has not landed yet
	_request_retexture()


## ⛔ COALESCED, and it has to be. A map with 66 materials expects ~200 PBR files,
## each arriving as its own asset_ready — and _apply_textures rebuilds the WAD
## atlas, the projection table and BOTH PBR atlases every call. Re-applying per
## arrival would be ~200 full atlas rebuilds during a join.
##
## The timer is a COALESCING window, not a poll: nothing here asks the network
## anything. Arrivals are still the trigger; this only decides how many of them
## share one rebuild.
func _request_retexture() -> void:
	if _retexture_pending:
		return
	_retexture_pending = true
	var t := get_tree().create_timer(RETEXTURE_DEBOUNCE_S)
	t.timeout.connect(_do_retexture)


func _do_retexture() -> void:
	_retexture_pending = false
	if _importer_for_retexture == null:
		return
	print("[MapDCLevel] assets arrived over the mesh — re-applying textures")
	_apply_textures(_importer_for_retexture)


## The PBR files THIS level uses — bounded by the materials the map references,
## not the 1509 entries in build_out.
##
## ⚠ NOT expressible as required_assets(): that runs at _ready, and
## material_names only exists after the import. This is the third case the
## catalogue has to serve — a fixed set that is only KNOWABLE post-import.
func _pbr_expected(names: PackedStringArray) -> PackedStringArray:
	var out := PackedStringArray()
	for raw in names:
		var n := String(raw).to_lower()
		for suf in PBR_SUFFIXES:
			out.append(n + suf)
	return out


## Offer the PBR files this machine holds. Deferred and timed rather than assumed
## cheap: this is ~3 hashes per material, and a map with 66 materials is ~200
## streaming reads at load. If that ever shows up in a frame graph the answer is
## to publish on a worker, not to skip it.
func _publish_pbr(names: PackedStringArray) -> void:
	var sync := get_node_or_null(^"/root/AssetSync")
	if sync == null or not sync.is_ready():
		return
	if not DirAccess.dir_exists_absolute(TEX_BUILD_DIR):
		return
	var t0 := Time.get_ticks_msec()
	var n := 0
	for fname in _pbr_expected(names):
		var src := TEX_BUILD_DIR.path_join(fname)
		if FileAccess.file_exists(src):
			if String(sync.publish_file("pbr", fname, src)) != "":
				n += 1
	print("[MapDCLevel] published %d PBR files in %d ms" % [n, Time.get_ticks_msec() - t0])


## A directory containing the PBR files under the names load_pbr expects, or "".
##
## Prefers the authoring tree untouched. Otherwise MATERIALISES one under
## user:// from whatever the room has: load_pbr takes a DIRECTORY and does
## dir.path_join(name + "_gloss.jpg"), so content-addressed
## user://content/<oid>.jpg cannot be handed to it directly — the expected
## filenames have to exist somewhere.
##
## A partial directory is USEFUL, not a failure: load_pbr already substitutes a
## flat colour per missing map and reports COVERAGE rather than success, so a
## level with half its PBR fetched renders half-detailed instead of albedo-only.
func _pbr_dir(names: PackedStringArray) -> String:
	if DirAccess.dir_exists_absolute(TEX_BUILD_DIR):
		return TEX_BUILD_DIR
	var sync := get_node_or_null(^"/root/AssetSync")
	if sync == null or not sync.is_ready():
		return ""
	var dst_abs := ProjectSettings.globalize_path(PBR_LOCAL_DIR)
	DirAccess.make_dir_recursive_absolute(dst_abs)
	var got := 0
	for fname in _pbr_expected(names):
		var target := dst_abs.path_join(fname)
		if FileAccess.file_exists(target):
			got += 1
			continue
		var have := String(sync.ensure("pbr", fname))
		if have == "":
			continue
		# COPY, not rename: the content-addressed original must stay put so this
		# peer keeps serving it. These are `size`-resized jpgs, a few KB each.
		if DirAccess.copy_absolute(ProjectSettings.globalize_path(have), target) == OK:
			got += 1
	if got == 0:
		return ""
	print("[MapDCLevel] PBR from the room: %d/%d files" % [got, _pbr_expected(names).size()])
	return dst_abs


## The external files this level needs, declared for map_core to publish.
##
## Every one of these is an absolute path into the AUTHORING machine's
## /storage/projects/gamedev/quake/ tree, and every consumer of them is guarded
## by a file_exists()/dir_exists() check that degrades SILENTLY — no atlas, no
## PBR, no rtlights, and the level renders bare on any machine that is not this
## one. Declaring them here is what lets every peer fetch them instead.
##
## ⚠ TEX_BUILD_DIR is a DIRECTORY, not a file, so it is NOT listed: publishing it
## needs per-file enumeration of the build output and belongs in its own change.
## Until then the PBR path still degrades to albedo-only off-machine.
func required_assets() -> Array:
	return [
		{"kind": "map", "name": "START.MAP", "path": MAP_PATH},
		{"kind": "wad", "name": "base.wad", "path": WAD_PATH},
		{"kind": "palette", "name": "palette.lmp", "path": PAL_PATH},
		{"kind": "rtlights", "name": "start.rtlights", "path": RTLIGHTS_PATH},
	]


## Offer our copy to the room. Cheap and idempotent: hashing is a streaming read
## (no whole-file buffer), and serving hands out a locator, not bytes — the file
## is read per chunk, at offset, only when a peer actually asks.
func _announce_source(path: String) -> void:
	if _persist == null or not ContentOverlay or not ContentOverlay.is_ready():
		return
	var h: String = ContentOverlay.publish(path)
	if h == "":
		push_warning("[MapDCLevel] could not publish %s to the overlay" % path)
		return
	var size: int = FileAccess.open(path, FileAccess.READ).get_length()
	_persist.persist_source(h, path.get_file(), size)
	print("[MapDCLevel] serving level source %s (%s, %d bytes) to the room"
			% [path.get_file(), h.left(12), size])


## No local .MAP: fetch the one this room announced.
##
## The durable record may not have loaded yet, and no peer may have announced one
## at all, so this retries off GameState.state_changed rather than failing once at
## _ready. A fetch with no holder attached is NOT an error either — it stays open
## and is re-sent to every peer that joins later.
func _request_source() -> void:
	if not ContentOverlay or not ContentOverlay.is_ready() or _persist == null:
		push_warning("[MapDCLevel] %s not found and no overlay — placeholder floor kept" % _map_path())
		return
	if not GameState.state_changed.is_connected(_on_state_for_source):
		GameState.state_changed.connect(_on_state_for_source)
	if not ContentOverlay.content_ready.is_connected(_on_source_arrived):
		ContentOverlay.content_ready.connect(_on_source_arrived)
	_try_request_source()


func _try_request_source() -> void:
	if _source_hash != "":
		return
	var src: Dictionary = _persist.find_source()
	if src.is_empty():
		return
	_source_hash = String(src.get("hash", ""))
	var dest: String = ContentOverlay.request(_source_hash, String(src.get("name", "level.map")))
	if dest == "":
		_source_hash = ""
		return
	print("[MapDCLevel] no local .MAP — fetching %s (%s, %d bytes) over the mesh"
			% [String(src.get("name", "?")), _source_hash.left(12), int(src.get("size", 0))])


func _on_state_for_source(key: String, _v: Variant, _c: int) -> void:
	if _source_hash == "" and key.begins_with("world:level:") and key.ends_with(":source"):
		_try_request_source()


func _on_source_arrived(content_hash: String, path: String) -> void:
	if content_hash != _source_hash:
		return
	print("[MapDCLevel] level source arrived: %s" % path)
	_import_from(ProjectSettings.globalize_path(path))


## The import transform, kept because the grid snap needs it. world = (quake -
## map_center) * WORLD_SCALE + aligned_offset, and map_center is itself snapped to
## Quake's 8-unit grid by the importer (for DC voxel alignment), which is why fine
## grids already land correctly and only coarse ones drift.
var _map_center := Vector3.ZERO
var _aligned_offset := Vector3.ZERO

var _source_hash: String = ""
## The .MAP actually imported — the local dev-box copy, or the one fetched off the
## mesh. Anything that re-reads the file afterwards (player_start) must use THIS,
## not MAP_PATH, or it silently no-ops on exactly the peers the overlay is for.
var _map_path_used: String = ""

## info_player_start in world space, once the import has resolved it. Subclasses
## anchor to this rather than to a hand-picked constant.
var _start_pos := Vector3.ZERO
var _has_start := false


## Where the level's origin lands. ZERO for a standalone arena — there is nothing
## to clear. map_dc_world lifts it to sit above the procedural ground, which is
## what map_dc_terrain's (0,20,0) has always been for.
func world_offset() -> Vector3:
	return Vector3.ZERO


## The SDF field the level's volume is subtracted from, or null when there is no
## procedural world to subtract it from.
##
## NULL IS NOT "SKIP AN OPTIONAL STEP" — it is the standalone-arena case, where
## §5a's seam genuinely vanishes because nothing else occupies the space. When a
## field IS returned the carve becomes load-bearing: it is the only thing keeping
## the terrain out of the level's rooms until §5a.2's convex decomposition of the
## interior replaces it. Returning null from a map that HAS a DCTerrain fills the
## level with hillside.
func carve_field() -> Node:
	return null


func _import_from(map_path: String) -> void:
	if not FileAccess.file_exists(map_path):
		push_warning("[MapDCLevel] %s not found — empty level, placeholder floor kept" % map_path)
		return
	_map_path_used = map_path

	var t0 := Time.get_ticks_msec()
	var importer := QuakeMapImporter.new()
	# Per-face UV projections need each texture's ORIGINAL size; header-only read.
	# Through the resolver: this machine's own copy if it has one, else the room's.
	# Was FileAccess.file_exists(WAD_PATH) — false on every peer but the author's.
	var _wad_for_dims := String(_wad_paths()[0])
	var tex_dims: Dictionary = QuakeWadLoader.load_dims(_wad_for_dims) if _wad_for_dims != "" else {}
	# light_parent=self: 265 shadowless Quake OmniLights give the interiors something
	# to read against.
	# include_sky=true: a Quake sky brush is SOLID (it blocks the player) and only
	# its rendering is special. The DCTerrain path has always dropped them, which
	# leaves holes in the map boundary — the exact-mesh level seals them.
	#
	# The offset and the carve come from the two hooks below rather than being
	# written here, because they are the ONLY things map_dc_world needs to differ
	# on. Everything past this line — textures, sky lights, rtlights, entities, the
	# gate, persistence, the editor — is identical for an arena and for a level
	# embedded in a procedural world, and copying it into a second map is how the
	# duplicate spawners happened (hyper-games #9 item 9).
	var field: Node = carve_field()
	var n: int = importer.import_file(level, map_path, WORLD_SCALE, world_offset(),
									  1.0, self, field != null, tex_dims, true, field)
	if n <= 0:
		push_warning("[MapDCLevel] import stamped 0 brushes — placeholder floor kept")
		return

	# add_brush only marks the store dirty; the node is already _ready so nothing
	# will rebuild on its own.
	_map_center = importer.out_map_center
	_aligned_offset = importer.out_aligned_offset
	level.rebuild()
	_importer_for_retexture = importer
	_hook_retexture()
	_publish_pbr.call_deferred(PackedStringArray(importer.material_names))
	_spawn_sky_area_lights(importer, _apply_textures(importer))
	_spawn_rtlights(importer)
	_spawn_entities(importer)
	_report_gate(n, Time.get_ticks_msec() - t0)

	_refresh_brush_view()
	# AFTER the import: the outliner's derived groups are counts over the brush
	# set, so registering at _ready would build a tree over an empty level.
	_register_outliner()
	_register_asset_browser()
	drop_placeholder_floor()
	_move_player_to_start(importer)
	# AFTER _move_player_to_start: it anchors the map's teleporters to info_player_start
	# by walking the map's DIRECT children, which adoption moves under SceneObjects.
	_adopt_scene_objects()


## Texture the level. DCLevel emits DCTerrain's exact vertex encoding (COLOR.r =
## material_id/255, CUSTOM0.xy = per-face projection candidates) precisely so this
## works, but it has no set_texture_atlas / set_projection_table of its own — so we
## build the material here in GDScript and set it as material_override on its
## MeshInstance3D. Nothing in the GDExtension needs to change.
## Material names of the form R<region>S<sub> encode a HIERARCHY: the PVS region
## and the geometry sub-room inside it. Region picks the hue, sub picks the value,
## so a coarse region reads as one colour family and its subdivisions are visible
## within it. "XX" in either field marks a face the two methods disagree on and is
## painted white.
func _apply_room_palette(mat: ShaderMaterial, importer: QuakeMapImporter) -> void:
	var names: Array = importer.material_names
	var img := Image.create(256, 1, false, Image.FORMAT_RGBF)
	img.fill(Color(0.5, 0.5, 0.5))
	var re := RegEx.new()
	re.compile("^R(\\d+|XX)S(\\d+|XX)$")
	var regions := {}
	for n in names:
		var m := re.search(String(n))
		if m and m.get_string(1) != "XX":
			regions[int(m.get_string(1))] = true
	var keys: Array = regions.keys(); keys.sort()
	for i in range(names.size()):
		var idx := i + 1                       # atlas/material ids are 1-based
		if idx > 255: break
		var m := re.search(String(names[i]))
		if m == null:
			continue
		if m.get_string(1) == "XX" or m.get_string(2) == "XX":
			img.set_pixel(idx, 0, Color(1, 1, 1))   # disagreement between methods
			continue
		var r := int(m.get_string(1))
		var sub := int(m.get_string(2))
		var slot: int = keys.find(r)
		var hue: float = float(slot) / maxf(1.0, float(keys.size()))
		# Value walks DOWN with the sub-room index so the hierarchy is legible:
		# same hue == same PVS region, different brightness == different geometry room.
		var val: float = clampf(1.0 - 0.16 * float(sub), 0.32, 1.0)
		img.set_pixel(idx, 0, Color.from_hsv(hue, 0.72, val))
	mat.set_shader_parameter("room_palette", ImageTexture.create_from_image(img))
	mat.set_shader_parameter("use_palette", true)
	print("[MapDCLevel] room palette: %d materials, %d regions" % [names.size(), keys.size()])


func _apply_textures(importer: QuakeMapImporter) -> Texture2DArray:
	var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
	if mi == null:
		return null
	# Lives HERE, next to the map, and deliberately NOT in res://shaders/ — that
	# directory carries a .gdignore (it holds dc-terrain's GPU compute .glsl files,
	# which the GDExtension loads itself via dladdr and Godot must not try to
	# import). A .gdshader placed there is invisible to the importer and absent
	# from the exported .pck, so load() returns null and the level ships
	# untextured. That is exactly how 0.771.0 shipped.
	var shader: Shader = load("res://scenes/maps/dc_level.gdshader")
	if shader == null:
		push_warning("[MapDCLevel] dc_level.gdshader failed to load — level stays untextured")
		return null

	var mat := ShaderMaterial.new()
	mat.shader = shader
	var atlas: Texture2DArray = null

	# RESOLVED ONCE, used for the atlas AND the native sky read below. Both used to
	# test FileAccess.file_exists(WAD_PATH) against an absolute path in the
	# authoring machine's /storage/projects/gamedev/quake tree, so on every other
	# peer both silently failed and the level rendered untextured, with no error.
	var _wp := _wad_paths()
	var wad_p := String(_wp[0])
	var pal_p := String(_wp[1])
	if wad_p == "" or pal_p == "":
		print("[MapDCLevel] WAD/palette not local yet — untextured until they arrive")

	# Atlas layer index == material_id - 1, matching the importer's 1-based ids.
	if wad_p != "" and pal_p != "":
		var res: Dictionary = QuakeWadLoader.load(wad_p, pal_p,
			PackedStringArray(importer.material_names), 64)
		if res.has("array"):
			atlas = res["array"]
			mat.set_shader_parameter("tex_atlas", atlas)
			# `layers` is a DICTIONARY (Variant type 27), not an Array. Casting it
			# with `as Array` yields null and .size() on null hard-crashes the game
			# with no GDScript error — which is exactly how 0.772.0 died, right
			# after the atlas finished building. map_dc_terrain.gd has always used
			# the duck-typed form; the cast was invented here. Do not add it back.
			var layers: Variant = res["layers"]
			print("[MapDCLevel] WAD atlas: %d layers" % int(layers.size()))

	# Per-face projection table as an RGBA32F data texture — the same layout
	# DCTerrain::set_projection_table builds in C++: 4 texels per row
	# (layer,u_off,v_off,0)(u_axis,0)(v_axis,0)(geometric normal,0), one row per
	# projection entry, fetched by the vertex's 1-based projection index.
	var entries: int = importer.projections.size() / 16
	if entries > 0:
		var img := Image.create_from_data(4, entries, false, Image.FORMAT_RGBAF,
										  importer.projections.to_byte_array())
		mat.set_shader_parameter("projection_table", ImageTexture.create_from_image(img))
		mat.set_shader_parameter("has_projection_table", true)
		mat.set_shader_parameter("projection_rows", entries)
		print("[MapDCLevel] projection table: %d entries" % entries)

	# Sky faces emit (stained-glass read). Collect the DISTINCT sky material ids —
	# a map can mix SKY1/SKY4 — and hand up to 4 to the shader, which compares them
	# against the per-vertex material id. -1 marks an unused slot.
	var sky_ids: Array[float] = []
	for v in importer.sky_volumes:
		var m := float(int(v["mat"]))
		if m > 0.0 and not sky_ids.has(m):
			sky_ids.append(m)
	var packed := Vector4(-1.0, -1.0, -1.0, -1.0)
	for i in mini(sky_ids.size(), 4):
		packed[i] = sky_ids[i]
	mat.set_shader_parameter("sky_mats", packed)
	# The sky at NATIVE 256x128 — two 128x128 layers side by side. The atlas resizes
	# every layer to a square, which blends the cloud and star halves into the mottled
	# noise the ceiling was showing, so the sky is loaded separately and unsquashed.
	if not sky_ids.is_empty():
		var sky_name: String = importer.material_names[int(sky_ids[0]) - 1]
		var sky_img: Image = null
		if wad_p != "" and pal_p != "":
			sky_img = QuakeWadLoader.load_native(wad_p, pal_p, sky_name)
		if sky_img != null:
			mat.set_shader_parameter("sky_tex", ImageTexture.create_from_image(sky_img))
			mat.set_shader_parameter("has_sky_tex", true)
			print("[MapDCLevel] sky texture %s at native %dx%d" % [
				sky_name, sky_img.get_width(), sky_img.get_height()])
		else:
			push_warning("[MapDCLevel] could not decode sky texture %s" % sky_name)
	mat.set_shader_parameter("sky_emission", SKY_EMISSION)
	if sky_ids.size() > 4:
		push_warning("[MapDCLevel] %d distinct sky materials, shader holds 4" % sky_ids.size())
	print("[MapDCLevel] emissive sky materials: %s (emission %.1f)" % [str(sky_ids), SKY_EMISSION])

	# PBR: roughness-from-gloss, emissive glow, DarkPlaces-derived normals. The loader
	# and the assets both already existed and were used by map_dc_terrain.gd:359 — the
	# level simply never called them, so it rendered albedo-only with a constant
	# ROUGHNESS 0.9. Layers come back in the SAME order as the diffuse atlas, so a
	# fragment's material layer indexes all three consistently.
	#
	# Guarded on the directory: this is a dev-authoring path (like WAD_PATH above), and
	# a machine without the texture build should lose PBR, not the level.
	var pbr_dir := _pbr_dir(PackedStringArray(importer.material_names))
	if pbr_dir != "":
		var pbr: Dictionary = QuakeWadLoader.load_pbr(
			pbr_dir, PackedStringArray(importer.material_names), 64)
		if pbr.has("glow") and pbr.has("norm"):
			mat.set_shader_parameter("glow_atlas", pbr["glow"])
			mat.set_shader_parameter("norm_atlas", pbr["norm"])
			mat.set_shader_parameter("use_pbr", true)
			# normal_strength is deliberately NOT set: the shader defaults it to 1.0 and
			# DarkPlaces applies the normal unscaled, so 1.0 IS the reference value.
			# map_dc_terrain.gd does not set it either. Adding a tuned constant here
			# would make the level disagree with both the terrain and the oracle.
			# Report COVERAGE, not success. Every name resolves to a layer whether or
			# not a map existed for it (missing → black → matte, no emission), so
			# "PBR on" says nothing about how much of the level is actually covered.
			print("[MapDCLevel] PBR atlases: gloss %d/%d, glow %d/%d, normal %d/%d layers" % [
				int(pbr["n_gloss"]), importer.material_names.size(),
				int(pbr["n_glow"]), importer.material_names.size(),
				int(pbr["n_norm"]), importer.material_names.size()])
		else:
			push_warning("[MapDCLevel] PBR build dir present but no atlases came back — level stays albedo-only")
	else:
		print("[MapDCLevel] no PBR build locally and none from the room — albedo-only")

	mat.set_shader_parameter("use_textures", not flat_room_colors)
	mat.set_shader_parameter("flat_mats", flat_room_colors)
	if flat_room_colors:
		_apply_room_palette(mat, importer)
	LevelPackMaterial.wear(level, mat)         # LevelMesh AND LevelPockets
	return atlas


## Water/lava/slime, teleporters and func_door movers. The importer already recovers
## all three; map_dc_terrain had private spawners for them and this map called none —
## the same gap rtlights had. Now shared in QuakeEntities so any map gets them.
## ⛔ RESOLVED BUT DELIBERATELY NOT RETRIED. Unlike _apply_textures, every call
## below SPAWNS NODES, so re-running this when a late WAD lands would duplicate
## every liquid, teleporter and door — the hyper-games #9 item 9 failure. A peer
## that joins before the WAD arrives therefore loses these for the life of the
## map load, which is a real gap and the honest cost of not making the spawners
## idempotent. Making them removable-and-rebuildable is the fix, and it is its own
## change.
func _spawn_entities(importer: QuakeMapImporter) -> void:
	var _ep := _wad_paths()
	var e_wad := String(_ep[0])
	var e_pal := String(_ep[1])
	if e_wad == "" or e_pal == "":
		print("[MapDCLevel] no WAD/palette — liquids, teleporters and doors skipped")
		return
	var liquids := QuakeEntities.spawn_liquids(self, importer, e_wad, e_pal)
	var tp: Array = QuakeEntities.spawn_teleporters(self, importer, e_wad, e_pal)
	var doors := QuakeEntities.spawn_doors(self, importer, e_wad, e_pal)
	# AFTER the spawn, and from what it produced: the links are drawn over the live
	# TeleportTrigger nodes, so a line exists exactly where the game will actually
	# move the player. A re-import respawns that container, so the lines must be
	# rebuilt with it — which they are, being in the same function.
	_ensure_entity_links().refresh(get_node_or_null("QuakeTeleporters"))
	print("[MapDCLevel] liquids %d/%d | teleporters %d wired (%d dests) + %d pads | doors %d | links %d" % [
		liquids, importer.liquid_volumes.size(), int(tp[0]), int(tp[2]), int(tp[1]), doors,
		_ensure_entity_links().link_count()])


## Replace the raw .MAP light entities with the curated DarkPlaces rtlights.
##
## The importer spawns an OmniLight3D per Quake `light*` entity into a "QuakeLights"
## child — 267 of them on START — but that path is a GUESS: omni_range is
## brightness x world_scale, energy is clamp(brightness/200, 0.5, 4.0), and shadows
## are hard-disabled. RtlightManager reads the authored .rtlights file instead,
## canonicalized against DarkPlaces r_shadow.c: real radius, per-light
## energy = ambient+diffuse+specular, `!`=noshadow and realtime/corona filtering,
## and EMA importance + hysteresis for shadow selection.
##
## map_dc_terrain has always done this after its KEY_M import; map_dc_level never
## did, which is why this level was lit by the fallback. Load order matters: this
## must run AFTER the import, because the rtlight transform reuses the importer's
## out_map_center / out_aligned_offset so lights land in the same world space as
## the geometry.
func _spawn_rtlights(importer: QuakeMapImporter) -> void:
	# A generated level carries its own light entities (authored by the level
	# designers of the maps its germs came from). RTLIGHTS_PATH is START-specific,
	# so loading it here would place START's lights in OUR map's space and then
	# delete the correct ones.
	if use_map_lights:
		print("[MapDCLevel] use_map_lights — keeping this map's own light entities")
		return
	# Resolved, but NOT retried — RtlightManager is add_child()ed below, so a
	# second pass would stack managers. Same reasoning as _spawn_entities.
	var rt_path := asset_path("rtlights", "start.rtlights", RTLIGHTS_PATH)
	if rt_path == "":
		print("[MapDCLevel] no rtlights at %s — keeping the .MAP light entities" % RTLIGHTS_PATH)
		return
	var mgr := RtlightManager.new()
	mgr.name = "RtlightManager"
	add_child(mgr)
	var n := mgr.setup(rt_path, importer.out_map_center, WORLD_SCALE,
					   importer.out_aligned_offset)
	if n > 0:
		# Only drop the fallback once the real lights actually loaded — otherwise a
		# bad rtlights file would leave the level pitch black.
		var maplights := get_node_or_null("QuakeLights")
		if maplights != null:
			maplights.queue_free()
		print("[MapDCLevel] rtlights: %d loaded — .MAP light entities replaced" % n)
	else:
		mgr.queue_free()
		print("[MapDCLevel] rtlights loaded 0 — keeping the .MAP light entities")


## One AreaLight3D per sky brush, sized to that brush's inward face and textured
## with its sky texture — the map author's own boundary marking driving the light.
##
## RUNTIME-INSTANTIATED ON PURPOSE, never declared in the .tscn. AreaLight3D is
## Godot 4.7+ (PR #108219, merged 2026-04-14); the CI export runs 4.6, where the
## class does not exist. A .tscn node of an unknown type silently degrades to a
## placeholder Node — exactly how 0.768.0 shipped an inert DCLevel — so the class
## is checked at runtime and simply skipped on 4.6. Under the godot-rtx build
## (4.7-beta, which has it) the lights appear with no other change.
func _spawn_sky_area_lights(importer: QuakeMapImporter, atlas: Texture2DArray) -> void:
	if not ClassDB.class_exists("AreaLight3D"):
		print("[MapDCLevel] AreaLight3D unavailable (needs 4.7+ / godot-rtx build) — sky lights skipped")
		return
	# OFF by default. Godot flags AreaLight3D as expensive (there is a merged PR
	# putting its cluster iterations behind a spec constant), and 19 of them over a
	# whole level is a real frame cost. Experimental like path tracing, so it is a
	# setting rather than something every player pays for silently.
	if not bool(Settings.get_value("sky_area_lights")):
		print("[MapDCLevel] sky area lights disabled (Settings > Sky Area Lights)")
		return
	var vols: Array = importer.sky_volumes
	if vols.is_empty():
		return

	var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
	var level_centre: Vector3 = mi.get_aabb().get_center() if mi != null else Vector3.ZERO

	var container := Node3D.new()
	container.name = "SkyAreaLights"
	add_child(container)

	var made := 0
	var tex_ok := 0
	for v in vols:
		var c: Vector3 = v["center"]
		var h: Vector3 = v["half"]
		# Which face of this box looks INTO the level? The one whose outward normal
		# most nearly points at the level centre. A sky brush is a boundary slab, so
		# that face is the one the player can actually see and be lit by.
		# A sky brush is a SLAB: its emitting face is the broad one, which is
		# perpendicular to the THINNEST axis. Choosing by "which face points at the
		# level centre" picked the end cap instead — [MEASURED] a half=(0.224,2.8,7.84)
		# wall produced a 0.448 x 5.6 m light off its end rather than the 5.6 x 15.68 m
		# face. Pick the thin axis for the normal, then use the level centre only to
		# choose WHICH WAY along it to face.
		var axis := 0
		for a in [1, 2]:
			if h[a] < h[axis]:
				axis = a
		var to_centre := level_centre - c
		var sign := 1.0 if to_centre[axis] >= 0.0 else -1.0
		var n := Vector3.ZERO
		n[axis] = sign                                  # inward-facing normal
		# Nudged INSIDE the room, not left coplanar with the face. The brush is solid,
		# rendered geometry: a light sitting exactly on (or within) its surface is
		# occluded by the very brush it belongs to.
		var face_centre := c + n * (h[axis] + SKY_LIGHT_INSET)
		var dims: Array[float] = []
		for a in 3:
			if a != axis:
				dims.append(h[a] * 2.0)

		var light: Node3D = ClassDB.instantiate("AreaLight3D")
		container.add_child(light)
		light.global_position = face_centre
		# Light is emitted along -Z of the basis, and look_at points -Z at the
		# target. Pick an up vector that is not parallel to n or the basis is
		# degenerate — a ceiling's normal IS Vector3.UP.
		var up := Vector3.UP if absf(n.dot(Vector3.UP)) < 0.99 else Vector3.FORWARD
		light.look_at(face_centre + n, up)
		light.set("area_size", Vector2(dims[0], dims[1]))
		# x4 the face size reached ~89 m, so a single sky slab lit most of the level
		# and every fragment in that volume paid for it. x1.5 keeps the glow local to
		# the opening it comes from.
		light.set("area_range", maxf(dims[0], dims[1]) * 1.5)
		# area_normalize_energy DIVIDES energy by surface area. With the default
		# energy of 1.0 an ~88 m^2 sky slab emitted ~0.011 — created, correct, and
		# completely invisible, which is exactly what "I don't see any arealight"
		# was. Off, with an explicit energy instead, so brightness is a number we
		# chose rather than a side effect of how big the brush happens to be.
		light.set("area_normalize_energy", false)
		light.set("light_energy", SKY_LIGHT_ENERGY)
		var textured := false
		if atlas != null:
			var layer: int = maxi(int(v["mat"]) - 1, 0)
			if layer < atlas.get_layers():
				var img := atlas.get_layer_data(layer)
				if img != null:
					light.set("area_texture", ImageTexture.create_from_image(img))
					textured = true
		if textured:
			tex_ok += 1
		made += 1
	# Report what actually happened. The previous version claimed "textured from the
	# sky brush" unconditionally while every light in fact had area_texture unset —
	# a log line that asserted an outcome it never checked.
	print("[MapDCLevel] sky area lights: %d (energy %.1f, %d textured)" % [
		made, SKY_LIGHT_ENERGY, tex_ok])


## Print measured-vs-target for every acceptance number in §6.2/§10, so a divergence
## is visible in the log rather than needing to be eyeballed.
func _report_gate(stamped: int, wall_ms: int) -> void:
	var s: Dictionary = level.get_stats()
	print("[MapDCLevel] import: %d brushes stamped in %d ms (rebuild %.1f ms)" % [
		stamped, wall_ms, float(s.get("build_ms", 0.0))])
	# Targets are the SKY-INCLUSIVE numbers, because that is what this map imports.
	# §6.2's published figures (12747 tris / 6487 faces / 1164 colliders) were
	# measured solid-only; promoting START's 19 sky brushes to solid adds 428
	# triangles, 225 faces and 19 colliders. [MEASURED] via
	# scenes/test/dc_level_gate.gd, which still hard-gates the solid-only config so
	# the csg4 port keeps a regression check against the published oracle.
	# colliders is 1164, NOT 1183: all 1183 brushes render and clip, but the 19 sky
	# brushes are non-solid so you can jump through them, so the collider count
	# lands back on the solid-only figure. Triangles/faces stay at the sky-inclusive
	# numbers precisely BECAUSE sky still clips.
	for row in [
			["triangles", int(s.get("triangles", 0)), 13175],
			["faces", int(s.get("faces", 0)), 6712],
			["colliders", int(s.get("colliders", 0)), 1164],
			["sky_brushes", int(s.get("sky_brushes", 0)), 19],
	]:
		var got: int = row[1]
		var want: int = row[2]
		print("[MapDCLevel]   %-10s %7d  target %7d  %s" % [
			row[0], got, want, "OK" if got == want else "DIVERGES"])
	# 34288.0 is the sky-inclusive area; §6.2's 32101 m^2 was solid-only. Missed on
	# the first pass — the count targets were re-baselined for sky and this one was
	# not, so it read "34288.0 target 32101.0" and looked like a divergence.
	print("[MapDCLevel]   %-10s %7.1f  target %7.1f m^2" % [
		"area", float(s.get("surface_area", 0.0)), 34288.0])
	print("[MapDCLevel]   %-10s %7.4f  target <=%.4f deg (sharp edges)" % [
		"normal dev", float(s.get("max_normal_deviation", 0.0)), 0.034])
	print("[MapDCLevel]   brushes=%d faces_removed=%d faces_fragmented=%d vertices=%d" % [
		int(s.get("brushes", 0)), int(s.get("faces_removed", 0)),
		int(s.get("faces_fragmented", 0)), int(s.get("vertices", 0))])
	_report_winding()


## Every metric above is INVARIANT under a global inversion — flip every triangle
## and the counts, area and normal deviation are all unchanged. 0.768.0 shipped
## inside-out with a fully passing gate for exactly that reason. So check winding
## explicitly, against a mesh Godot itself generates rather than against a
## remembered convention: for a correctly-wound front face, cross(v1-v0, v2-v0)
## points AWAY from the shading normal.
func _report_winding() -> void:
	var mi := level.get_node_or_null("LevelMesh") as MeshInstance3D
	if mi == null or mi.mesh == null or mi.mesh.get_surface_count() == 0:
		return
	var want := _agree_fraction(BoxMesh.new().surface_get_arrays(0))
	var got := _agree_fraction(mi.mesh.surface_get_arrays(0))
	var ok := absf(want - got) < 0.5
	print("[MapDCLevel]   %-10s %6.1f%%  godot %6.1f%%  %s" % [
		"winding", got * 100.0, want * 100.0,
		"OK" if ok else "INVERTED — renders inside out"])


## Fraction of triangles whose geometric normal agrees with the stored shading
## normal. Godot's own meshes read 0%; a value near 100% means inverted winding.
func _agree_fraction(arrays: Array) -> float:
	var verts: PackedVector3Array = arrays[Mesh.ARRAY_VERTEX]
	var norms: PackedVector3Array = arrays[Mesh.ARRAY_NORMAL]
	var idx: PackedInt32Array = arrays[Mesh.ARRAY_INDEX]
	var agree := 0
	var total := 0
	for t in range(0, idx.size(), 3):
		var geo := (verts[idx[t + 1]] - verts[idx[t]]).cross(verts[idx[t + 2]] - verts[idx[t]])
		if geo.length() < 1e-12:
			continue
		if geo.normalized().dot(norms[idx[t]]) > 0.0:
			agree += 1
		total += 1
	return float(agree) / float(maxi(total, 1))


## Put the local player where Quake intends, instead of the teleporter's guessed
## drop point (which is now inside the level's interior volume).
func _move_player_to_start(importer: QuakeMapImporter) -> void:
	var f := FileAccess.open(_map_path_used if _map_path_used != "" else _map_path(), FileAccess.READ)
	if f == null:
		return
	var start: Dictionary = importer.player_start(f.get_as_text())
	f.close()
	if start.is_empty():
		print("[MapDCLevel] no info_player_start — leaving player at the teleport drop")
		return
	# Recorded whether or not a local player exists to move — a headless peer has
	# none, and map_dc_world still needs somewhere defensible to put the way out.
	# A return teleporter placed by guesswork ends up inside rock.
	_start_pos = start["pos"]
	_has_start = true
	_anchor_teleporters_to_start()
	var players := get_tree().root.get_node_or_null("Game/Players")
	if players == null:
		return
	for p in players.get_children():
		if not p.is_multiplayer_authority():
			continue    # only ever move OUR player; peers own their own position
		# +1 m: Quake's entity origin is the player's centre, not their feet.
		p.global_position = (start["pos"] as Vector3) + Vector3(0, 1.0, 0)
		p.velocity = Vector3.ZERO
		if "grapple_hooked" in p:
			p.grapple_hooked = false
		print("[MapDCLevel] player -> info_player_start %s" % str(p.global_position))
		return


## Move this scene's teleporters to the resolved info_player_start.
##
## Their scene positions are authored constants that suit START.MAP; for any other
## .MAP - a generated level especially - they land inside rock, which is exactly the
## "way out placed by guesswork" the comment above warns about. _start_pos is the one
## point in the level we KNOW is open, so anchor to it.
##
## Offset in a ring rather than ON the start: the teleporter hitbox is 6 m tall and
## the player spawns at the centre, so a co-located pad fires instantly and loops.
const TELEPORT_RING := 4.5

func _anchor_teleporters_to_start() -> void:
	if not _has_start:
		return
	var pads: Array[Node3D] = []
	for c in get_children():
		if c is Node3D and "target_map" in c:
			pads.append(c)
	if pads.is_empty():
		return
	for i in range(pads.size()):
		var a: float = TAU * float(i) / float(pads.size())
		pads[i].global_position = _start_pos + Vector3(cos(a) * TELEPORT_RING, 0.0,
													   sin(a) * TELEPORT_RING)
	print("[MapDCLevel] anchored %d teleporter(s) to info_player_start" % pads.size())


## Dev trigger for carving — the first thing that actually DRIVES the edit API.
##
## DCLevel has exposed apply_carve_sphere/undo_carve to GDScript since the level
## pivot landed, and nothing in the game called either of them, so the whole carve
## path was unreachable from a running build. This is the smallest thing that makes
## it reachable and therefore testable in the place it has to work.
##
##   K            carve a 0.6m sphere where you are looking
##   Shift+K      undo the last carve
##
## Still a dev keybind on the map rather than a player ability, but no longer for
## the old reason. Edits ARE durable now: `world:level:` is in both
## MAP_SCOPED_PREFIXES and AUTHOR_SCOPED_PREFIXES and is routed to DURABLE (it was
## missing from the route table until 0.843.0, which is why nothing written before
## that survived a reload), and every edit here is a journal record that folds back
## on load. What keeps it a dev binding is the UI, not the storage.
## NO UNDO STACK LIVES HERE. It used to: an Array of this session's carve dots,
## popped by Shift+K — which meant undo could not reach a previous session's edits
## and could not reach a face move or a fill at all. The journal already records
## every edit with its author and seq, so "my most recent edit" is a QUERY
## (LevelPersistence.undo_last), and it survives restarts because there is nothing
## to survive. A second source of truth beside the journal is the exact mistake the
## journal exists to prevent.
var _persist: LevelPersistence = null


## Durable carves. Created here rather than in the .tscn so the scene stays a
## plain instance of the map — the persistence layer is behaviour, not content.
## The wireframe. Created here rather than in the .tscn for the same reason the
## persistence node is: it is behaviour, not content.
var _brush_view: BrushView = null

## The entity wiring, which the rendered level shows nothing of — hyper-games #31.
var _entity_links: EntityLinks = null

## The active grid on the aimed surface (#31). Shown/hidden per player (pref "grid_view").
var _editor_grid: EditorGrid = null


## The operator chain. One at a time, Escape first-wins — see operator_stack.gd.
var _ops: OperatorStack = null
var _knife: KnifeOperator = null
var _move: MoveOperator = null
var _rotate: RotateOperator = null
## A selected shape's face handle drag (§17.7 Q3a). Preloaded, not by class name: a new
## class_name is unknown until the next import refreshes the class cache.
const ShapeHandleOperatorScript := preload("res://scenes/maps/operators/shape_handle_operator.gd")
var _handle_op: LevelOperator = null
var _scale: ScaleOperator = null
var _mirror: MirrorOperator = null
var _curve_op: CurveOperator = null
var _cable_op: CurveOperator = null         # the curve tool in cable mode (#120 L3)
var _curve_edit: CurveEditOperator = null
var _grab_op: ElementOperator = null
var _extrude: ExtrudeOperator = null
var _box: CreateBoxOperator = null


## Every operator needs the same two references and the same parenting; doing it four
## times by hand is three chances to forget one.
func _make_op(op: LevelOperator, node_name: String) -> LevelOperator:
	op.name = node_name
	op.level = level
	op.map = self
	add_child(op)
	op.load_prefs()            # E4: the real map remembers each tool's last settings
	return op


## The active tool's readout for the crosshair label (BuildHud.readout_provider).
func _op_readout() -> String:
	return _ops.active.readout() if _ops != null and _ops.active != null else ""


func _ensure_ops() -> OperatorStack:
	if _ops != null and is_instance_valid(_ops):
		return _ops
	_ops = OperatorStack.new()
	_ops.name = "OperatorStack"
	add_child(_ops)
	_knife = _make_op(KnifeOperator.new(), "Knife")
	_move = _make_op(MoveOperator.new(), "Move")
	_rotate = _make_op(RotateOperator.new(), "Rotate")
	_handle_op = _make_op(ShapeHandleOperatorScript.new(), "FaceHandle")
	_scale = _make_op(ScaleOperator.new(), "Scale")
	_mirror = _make_op(MirrorOperator.new(), "Mirror")
	_curve_op = _make_op(CurveOperator.new(), "Curve")
	_cable_op = CurveOperator.new()
	_cable_op.as_cable = true
	_cable_op = _make_op(_cable_op, "Cable")
	_curve_edit = _make_op(CurveEditOperator.new(), "CurveEdit")
	_drop_op = _make_op(DropCableOperatorScript.new(), "DropCable")   # #120 L3 §14.3
	_edges_op = _make_op(CableEdgesOperatorScript.new(), "CableEdges")   # #120 L3 §14.5
	_bundle_op = _make_op(CableBundleOperatorScript.new(), "CableBundle")   # #120 L3 §14.6
	_grab_op = _make_op(ElementOperator.new(), "Grab")
	_extrude = _make_op(ExtrudeOperator.new(), "Extrude")
	_box = _make_op(CreateBoxOperator.new(), "CreateBox")
	_make_op(BoxCutterOperator.new(), "BoxCutter")   # #120 L2 §13: found by name ("BoxCutter")
	# The stack scroll preview ends when the selection moves off its subject or a tool starts.
	selection_changed.connect(func() -> void: preload("res://scenes/maps/stack_ops.gd").clear_if_moved(self))
	_ops.changed.connect(func(op: LevelOperator) -> void: if op != null: preload("res://scenes/maps/stack_ops.gd").scroll_clear(self))
	# The HUD shows which operator is live and what it is set to, so a modal tool is
	# never a mode you are in without being told.
	_ops.changed.connect(func(_op: LevelOperator) -> void: _update_op_hud())
	# While a tool is up the panels take no hover either (BuildDock.set_modal; #124).
	_ops.changed.connect(func(_op: LevelOperator) -> void: _sync_modal())
	GameMode.build_submode_changed.connect(func(_m: String) -> void: _sync_modal())
	return _ops


func _update_op_hud() -> void:
	if BuildHud == null:
		return
	BuildHud.set_operator(_ops.status_line() if _ops != null else "")
	# THE TOOL'S HELP (H, #120 F3): its key table with the state inline, while open.
	var help := ""
	if _ops != null and _ops.active != null and _ops.active.has_modal() and _ops.active.modal().help_open:
		help = _ops.active.modal().help_text(_ops.active.op_name().to_upper())
	if BuildHud.has_method("set_tool_help"):
		BuildHud.set_tool_help(help)
	var mode_line := _sel_mode
	if _sel_brushes.size() > 1:
		# Said out loud, because a selection you have forgotten the size of is how a
		# CSG operation eats a brush you did not mean to include.
		mode_line = "%s · %d selected, active #%d" % [_sel_mode, _sel_brushes.size(), _sel_brush]
	BuildHud.set_select_mode(mode_line)


## The link renderer hangs off the MAP, not off `level` like the wireframe does:
## its vertices are the trigger volumes' world positions and the destinations the
## importer resolved, neither of which lives in the brush store's space. The node
## is top_level, so the parent's transform is not applied to them either way.
func _ensure_entity_links() -> EntityLinks:
	if _entity_links != null and is_instance_valid(_entity_links):
		return _entity_links
	_entity_links = EntityLinks.new()
	_entity_links.name = "EntityLinks"
	add_child(_entity_links)
	_entity_links.visible = _links_visible()
	return _entity_links


func _ensure_editor_grid() -> EditorGrid:
	if _editor_grid != null and is_instance_valid(_editor_grid):
		return _editor_grid
	_editor_grid = EditorGrid.new()
	add_child(_editor_grid)
	_editor_grid.hide_grid()
	return _editor_grid


func _grid_view_on() -> bool:
	return overlay_shown("grid")              # an overlay since keymap step 6.5


## Per frame: the grid follows the crosshair while building in the Level editor.
## `hit` is the frame's one crosshair ray, shared with the brush-view aim.
func _update_editor_grid(hit: Dictionary) -> void:
	var want := GameMode.is_building() and not in_bot_editor() and _grid_view_on() \
		and hit.has("position") and hit.has("normal")
	if not want:
		if _editor_grid != null and is_instance_valid(_editor_grid) and _editor_grid.visible:
			_editor_grid.hide_grid()
		return
	_ensure_editor_grid().show_at(hit["position"], hit["normal"], _grid_step())


func _ensure_brush_view() -> BrushView:
	if _brush_view != null and is_instance_valid(_brush_view):
		return _brush_view
	_brush_view = BrushView.new()
	_brush_view.name = "BrushView"
	_brush_view.level = level
	level.add_child(_brush_view)          # the store's space, like the face highlight
	_push_overlays(_brush_view)
	_brush_view.set_shown(GameMode.is_building())
	return _brush_view


## Geometry changed, so the outlines are stale. Cheap to call — it only regenerates
## when the view is actually visible.
func _refresh_brush_view() -> void:
	var v := _ensure_brush_view()
	if v.visible:
		v.refresh()


## Corner dots are what you aim at to pick a VERTEX. In edge or face mode they are
## the wrong affordance drawn over the right one — which is why an edge selection
## "shows up as two vertices": the line was there, with a corner dot blazing at each
## end of it.
func _sync_handle_visibility() -> void:
	var v := _ensure_brush_view()
	v.show_handles = (_sel_mode == "vertex")


## The organizer — hyper-games #28. Registered by the MAP for the same reason the
## terrain panel is: BuildDock.register is idempotent on id, so re-entering a map
## replaces the panel instead of stacking a second one.
##
## Keyed by the SOURCE HASH, like the shell cache, so collections made in one
## level do not show up in another. Nothing here is durable or replicated — see
## OutlinerModel's header for why that is the decision that made collections
## buildable at all.
func _register_outliner() -> void:
	if level == null or not is_instance_valid(level):
		return
	var Panel := load("res://scenes/game/outliner_panel.gd")
	if Panel == null:
		push_error("[MapDCLevel] outliner_panel.gd failed to load — no Outliner panel")
		return
	var key := ""
	if _persist != null and _persist.has_method("find_source"):
		key = String(_persist.find_source().get("hash", ""))
	var panel := VBoxContainer.new()
	panel.set_script(Panel)
	panel.setup(self, key)
	# RIGHT, alone. The organizer is a standing view of the whole scene; every
	# other panel is a tool for whatever you are doing this minute. Sharing one
	# edge meant the tree scrolled off the moment a second panel opened.
	# The organizer area, over the right column (Blender's; docs/editor-areas.md §4.3).
	BuildDock.register("outliner", "Outliner", panel, true, BuildDock.SIDE_ORGANIZER)
	_outliner = panel


var _outliner: Node = null


## Authored scene objects, folded from the same journal as the brushes. Made
## before persistence because _persist is handed a reference to it.
func _ensure_objects() -> Node:
	if _objects != null and is_instance_valid(_objects):
		return _objects
	_objects = SceneObjects.new()
	_objects.name = "SceneObjects"
	add_child(_objects)
	return _objects


var _objects: Node = null


## CREATE A NODE — the durable half of hyper-games #28.
##
## Godot's _do_create ends in `set_owner`, which is what makes a node survive
## into the saved scene. There is no saved scene here, so the equivalent is that
## the create is JOURNALLED: two phase, exactly like a brush create, because the
## record needs its durable name before anything can be built under it.
##
## `xform` is where it goes, in world space. Returns the dot, or "" if refused.
func create_object(type_id: String, xform: Transform3D) -> String:
	if not PrefabCatalogue.has(type_id):
		push_warning("[MapDCLevel] no prefab '%s' — nothing created" % type_id)
		return ""
	_ensure_persistence()
	var dot: String = _persist.reserve_dot()
	if not _persist.record_at(dot, "object.create", {
			"type": type_id, "xform": SceneObjects.xform_to(xform)}):
		push_warning("[MapDCLevel] the journal refused an object.create")
		return ""
	# The fold builds it — here and on every other peer by the same path, so what
	# you see is what they will see rather than a local object that happens to
	# match. Same reason the brush operators do not add geometry directly.
	_persist.refold()
	print("[MapDCLevel] created %s at %s (%s)" % [type_id, str(xform.origin), dot])
	return dot


## MOVE a created object — hyper-games #28, and what `,`/`.` were a stopgap for.
##
## Absolute, not a delta, because the fold replays the whole journal: a relative
## move would compound on every re-fold. So the record carries where the object
## ENDS UP, the last one in (seq, author) order wins, and undoing one falls back
## to the position before it with no inverse to compute.
func move_object(dot: String, xform: Transform3D) -> bool:
	if dot == "":
		return false
	_ensure_persistence()
	if String(_persist.instance_of(dot)) != "":
		print("[MapDCLevel] %s is part of a placed asset — move the placement (or Make Real it)" % dot)
		return false
	var op: String = _persist.reserve_dot()
	if not _persist.record_at(op, "object.transform", {
			"target": dot, "xform": SceneObjects.xform_to(xform)}):
		push_warning("[MapDCLevel] the journal refused an object.transform")
		return false
	_persist.refold()
	return true


## SET PROPERTIES of a created object — object.set_prop (#41 §7 item 5).
##
## `props` is {prop id: wire value} (a colour / vec3 = 3 floats). ONE record, one
## gesture, so one Ctrl+Z takes back the whole change and History names it by
## `label` ("Set Colour"). Not filtered here: the fold applies only what the
## catalogue lists for this prefab, on every peer, so this writer and a remote one
## cannot disagree about what took.
func set_object_props(dot: String, props: Dictionary, label: String = "") -> bool:
	if dot == "" or props.is_empty():
		return false
	_ensure_persistence()
	_persist.begin_gesture(label)
	var ok: bool = _persist.record_at(_persist.reserve_dot(), "object.set_prop",
			{"target": dot, "props": props})
	_persist.end_gesture()
	if not ok:
		push_warning("[MapDCLevel] the journal refused an object.set_prop")
		return false
	_persist.refold()
	return true


## PLACE A BOT at the crosshair (the Bot editor's "Place bot"): a `bot` scene object
## with its brain + body, ONE gesture ("Add bot"), selected so the Object panel shows
## its settings at once. Returns the dot, or "".
func place_bot(brain: String, body: String) -> String:
	if not BrainCatalogue.has(brain) or not PrefabCatalogue.BOT_BODIES.has(body):
		return ""
	_ensure_persistence()
	_persist.begin_gesture("Add bot")
	var dot := create_object("bot", object_spawn_transform())
	if dot != "":
		set_object_props(dot, {"brain": brain, "body": body})
	_persist.end_gesture()
	if dot != "":
		select_object(dot)
	return dot


## Brush-authoring actions, ignored in the Bot editor workspace.
const LEVEL_ONLY_ACTIONS: Array[String] = ["builder.create_box", "builder.carve", "builder.knife",
	"builder.extrude", "builder.move_in", "builder.move_out", "builder.texture_apply",
	"builder.texture_pick", "build_mode.csg_subtract", "build_mode.csg_intersect",
	"build_mode.csg_merge", "build_mode.csg_discard", "build_mode.faces_hull",
	"build_mode.faces_extrude", "build_mode.sel_vertex", "build_mode.sel_edge",
	"build_mode.sel_face", "build_mode.sel_brush"]


## True in the Bot editor workspace (BuildDock).
func in_bot_editor() -> bool:
	var dock := get_node_or_null("/root/BuildDock")
	return dock != null and dock.has_method("workspace") and String(dock.workspace()) == "bot"


## PLACE A MODEL the room already has, by content hash, at the crosshair. ONE gesture
## ("Add model <name>"): the create and its first set_prop undo together, or an undo
## would leave an empty grey box behind. Returns the dot, or "".
func place_model(oid: String, model_name: String) -> String:
	if not ModelCache.is_oid(oid):
		return ""
	_ensure_persistence()
	_persist.begin_gesture("Add model %s" % model_name)
	var dot := create_object("model", object_spawn_transform())
	if dot != "":
		set_object_props(dot, {"asset": oid, "name": model_name})
	_persist.end_gesture()
	if dot != "":
		select_object(dot)
	return dot


## IMPORT a .glb from this machine's disk and place it (hyper-games #93 models plan;
## any member may import — user, 2026-10-03). {dot, oid, reason}.
##
##   1. GlbCheck HERE, before anything is published: the room should never be offered
##      a file every peer would refuse.
##   2. Copy into user://content/<hash>.glb — the overlay serves a LOCATOR, so serving
##      the user's own path would break the moment they moved the file; the content
##      directory is ours, and a file there is named by what it is.
##   3. Name it in the room's library (world:assets:model:<name>, newest wins) so other
##      members can place it too — placements themselves pin the HASH.
##   4. place_model.
func import_model(path: String) -> Dictionary:
	var out := {"dot": "", "oid": "", "reason": ""}
	var bytes := PackedByteArray()
	if path.get_extension().to_lower() == "gltf":
		# A .gltf is PACKED into a .glb here, side files and all (GltfPacker): the room
		# only ever receives self-contained .glb bytes, so every peer's "no uri" rule holds.
		var pk := GltfPacker.pack_file(path)
		if not bool(pk["ok"]):
			out["reason"] = String(pk["reason"])
			return out
		bytes = pk["bytes"]
	else:
		var f := FileAccess.open(path, FileAccess.READ)
		if f == null:
			out["reason"] = "cannot open %s" % path.get_file()
			return out
		if f.get_length() > GlbCheck.MAX_BYTES:
			out["reason"] = "file too large (%d MB, limit %d)" % [f.get_length() >> 20, GlbCheck.MAX_BYTES >> 20]
			return out
		bytes = f.get_buffer(f.get_length())
		f.close()
	var v := GlbCheck.check(bytes)
	if not bool(v["ok"]):
		out["reason"] = String(v["reason"])
		return out
	if ContentOverlay == null or not ContentOverlay.is_ready():
		out["reason"] = "asset transfer unavailable in this build"
		return out
	DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(ModelCache.CONTENT_DIR))
	var tmp := ModelCache.CONTENT_DIR.path_join("import-%d.glb" % Time.get_ticks_usec())
	var w := FileAccess.open(tmp, FileAccess.WRITE)
	if w == null:
		out["reason"] = "cannot write the content cache"
		return out
	w.store_buffer(bytes)
	w.close()
	var oid: String = ContentOverlay.content_hash_of_file(tmp)
	if not ModelCache.is_oid(oid):
		out["reason"] = "could not hash the file"
		return out
	var final := ModelCache.path_of(oid)
	if FileAccess.file_exists(final):
		DirAccess.remove_absolute(ProjectSettings.globalize_path(tmp))   # our own scratch copy
	else:
		DirAccess.rename_absolute(ProjectSettings.globalize_path(tmp), ProjectSettings.globalize_path(final))
	ContentOverlay.publish(final)           # serve it from its content-addressed home
	var model_name := _model_name_from(path.get_file().get_basename())
	if AssetSync != null:
		AssetSync.publish_asset("model", model_name, oid, bytes.size(), "glb")
	out["oid"] = oid
	out["dot"] = place_model(oid, model_name)
	if out["dot"] == "":
		out["reason"] = "the journal refused the placement"
	print("[MapDCLevel] imported model %s (%s, %d tris) -> %s" % [model_name, oid.substr(0, 12), int(v["triangles"]), out["dot"]])
	return out


## A file name made into a library name: printable, no ':' (it is part of a state key),
## at most 64 characters.
static func _model_name_from(base: String) -> String:
	var s := ""
	for ch in base:
		var c := ch.unicode_at(0)
		if c >= 32 and ch != ":" and ch != "/" and ch != "\\":
			s += ch
	s = s.strip_edges().substr(0, 64)
	return s if s != "" else "model"


## DELETE objects: one object.remove each, ONE gesture ("Delete 3 objects"), so one Ctrl+Z
## brings them all back with everything they had. Returns how many were removed.
## DELETE ACTS ON THE SELECTION (2026-10-05): selected objects, else selected brushes,
## else — nothing selected — the aimed brush. The key used to go straight to
## _delete_aimed(), which never read the selection, so the brush you had clicked was not
## what Delete removed. The outliner calls this too. Returns how many things went.
func delete_selection() -> int:
	# THE UNION (§17.10): everything held goes in ONE gesture. Brushes FIRST — their ids are
	# only good until the next fold, and a deleted cable takes its curve with it (so a curve
	# selected beside its cable is already gone, not refused as "used").
	var kinds := int(not _sel_brushes.is_empty()) + int(not _sel_objects.is_empty()) + int(not _sel_curves.is_empty())
	if kinds > 1 or _sel_curves.size() > 1:
		_ensure_persistence()
		var brushes: Array = _sel_brushes.duplicate()
		var objs: Array = _sel_objects.duplicate()
		var curves: Array = _sel_curves.duplicate()
		_persist.begin_gesture("Delete")
		var n := 0
		if not brushes.is_empty():
			n += delete_brushes(brushes)
		if not objs.is_empty():
			n += delete_objects(objs)
		var refused: Array = []
		for cd: String in curves:
			if not curve_states().has(cd):
				continue                         # went with its cable above
			if delete_curve(cd) == 0:
				refused.append(_last_refusal)
		_persist.end_gesture()
		_clear_face_selection()
		_last_refusal = "; ".join(refused)
		return n
	if _sel_curve != "":
		return delete_curve(_sel_curve)
	if not _sel_objects.is_empty():
		return delete_objects(_sel_objects.duplicate())
	if not _sel_brushes.is_empty():
		return delete_brushes(_sel_brushes.duplicate())
	_delete_aimed()
	return 0


## Delete brushes by id, ONE gesture. A piece of a live parametric shape deletes the
## WHOLE shape (shape.remove — its pieces are read-only, docs/parametric-shapes-design.md
## §7); every other brush gets a brush.remove. Ctrl+Z brings it all back.
func delete_brushes(ids: Array) -> int:
	_ensure_persistence()
	var shapes: Dictionary = _persist.live_shapes()
	var shape_targets := {}
	var n := 0
	_persist.begin_gesture("Delete")
	var asset_targets := {}
	for id: Variant in ids:
		var g: String = String(level.get_brush_group(int(id)))
		if shapes.has(g):
			shape_targets[g] = true
			continue
		# A PLACED ASSET's piece deletes the placement (#120 L4): its pieces are read-only.
		var pa: String = String(_persist.instance_of(g)) if g != "" else ""
		if pa != "":
			asset_targets[pa] = true
			continue
		var bkey: String = String(level.get_brush_key(int(id)))
		if bkey != "" and _persist.record("brush.remove", {"brush_key": bkey}) != "":
			n += 1
	for g: String in shape_targets:
		if _persist.remove_shape(g) != "":
			n += 1
	for pa2: String in asset_targets:
		if _persist.record("asset.remove", {"target": pa2}) != "":
			n += 1
	_persist.end_gesture()
	if n > 0:
		_clear_face_selection()
		_persist.refold()
	print("[MapDCLevel] deleted %d brush(es) / shape(s) / placed asset(s) — Ctrl+Z to bring them back" % n)
	return n


## X KEEPS THE CHILDREN (#120 F1, docs/node-tree-and-stacks.md §2.4): each deleted
## object's direct children (those not being deleted too) first move up to its nearest
## surviving ancestor, keeping their place in the world, so the fold's cascade (an
## object.remove takes its children, SceneObjects) finds none. The cascade itself is left
## as it is — it is what every journal written so far means. `hierarchy` (Delete
## Hierarchy) skips the move-up: the children go too. One gesture either way.
func delete_objects(dots: Array, hierarchy: bool = false) -> int:
	_ensure_persistence()
	# A PLACED ASSET's object deletes the placement (#120 L4), never the object alone.
	var assets := {}
	var own: Array = []
	for d0: Variant in dots:
		var pa := String(_persist.instance_of(String(d0)))
		if pa != "":
			assets[pa] = true
		else:
			own.append(d0)
	if not assets.is_empty():
		_persist.begin_gesture("Delete")
		for pa3: String in assets:
			_persist.record("asset.remove", {"target": pa3})
		if not own.is_empty():
			delete_objects(own, hierarchy)
		_persist.end_gesture()
		_clear_face_selection()
		_persist.refold()
		return assets.size() + own.size()
	var n := 0
	_persist.begin_gesture("Delete %d object%s" % [dots.size(), "" if dots.size() == 1 else "s"])
	if not hierarchy and _objects != null and is_instance_valid(_objects):
		var going := {}
		for d: Variant in dots:
			going[String(d)] = true
		var moves := {}                        # new parent -> [children]
		for e: Dictionary in (_objects.entries() as Array):
			var cd := String(e["dot"])
			var pd := String(e.get("parent", ""))
			if going.has(cd) or not going.has(pd):
				continue
			var up := pd
			while up != "" and going.has(up):
				up = object_parent(up)
			if not moves.has(up):
				moves[up] = []
			(moves[up] as Array).append(cd)
		for up: String in moves:
			parent_objects(moves[up], up, "Delete")
	for d: Variant in dots:
		if object_node(String(d)) == null:
			continue
		if _persist.record_at(_persist.reserve_dot(), "object.remove", {"target": String(d)}):
			n += 1
	_persist.end_gesture()
	if n > 0:
		_clear_face_selection()
		_persist.refold()
	print("[MapDCLevel] deleted %d object(s) — Ctrl+Z to bring them back" % n)
	return n


## MOVE several objects at once ({dot: Transform3D}, absolute, SCENE space): one
## object.transform each, ONE gesture and ONE refold — what group G / R / O confirm with.
## Each target is written in its PARENT's space (object.parent), so the tools never need
## to know about the hierarchy. A top-level object's scene transform IS its own.
func move_objects(moves: Dictionary, label: String = "") -> int:
	_ensure_persistence()
	var n := 0
	_persist.begin_gesture(label)
	for d: String in moves:
		if String(_persist.instance_of(d)) != "":
			continue                           # a placed asset's object (#120 L4): read-only
		var local: Transform3D = _objects.local_for(_objects.parent_of(d), moves[d] as Transform3D) \
			if _objects != null and is_instance_valid(_objects) else moves[d] as Transform3D
		if _persist.record_at(_persist.reserve_dot(), "object.transform",
				{"target": d, "xform": SceneObjects.xform_to(local)}):
			n += 1
	_persist.end_gesture()
	if n > 0:
		_persist.refold()
	return n


## PARENT objects (demo sprint §3): one object.parent each, ONE gesture. `parent` "" =
## the top level. Each child keeps its world place (its xform is worked out in the new
## parent's space). Refused per child: the parent itself, a cycle (the parent is the
## child or below it), a bot as a child, a child already there. Returns how many moved.
func parent_objects(dots: Array, parent: String, label: String = "") -> int:
	if _objects == null or not is_instance_valid(_objects):
		return 0
	if parent != "" and object_node(parent) == null:
		return 0
	_ensure_persistence()
	var n := 0
	_persist.begin_gesture(label if label != "" else ("Clear parent" if parent == "" else "Parent"))
	for v: Variant in dots:
		var d := String(v)
		var node := object_node(d) as Node3D
		if node == null or d == parent or _objects.parent_of(d) == parent:
			continue
		if parent != "" and (_objects.is_ancestor(d, parent)
				or SceneObjects.NEVER_CHILD.has(String(node.get_meta("object_type", "")))):
			continue
		var local: Transform3D = _objects.local_for(parent, _objects.scene_xform(node))
		if _persist.record_at(_persist.reserve_dot(), "object.parent",
				{"target": d, "parent": parent, "xform": SceneObjects.xform_to(local)}):
			n += 1
	_persist.end_gesture()
	if n > 0:
		_persist.refold()
	print("[MapDCLevel] %s %d object(s)%s" % ["unparented" if parent == "" else "parented", n,
		"" if parent == "" else " to " + parent])
	return n


## The parent's dot, or "" (top level / not an object).
func object_parent(dot: String) -> String:
	return _objects.parent_of(dot) if _objects != null and is_instance_valid(_objects) else ""


## An object's transform in SCENE space — what the tools read and write. Same as its own
## transform for a top-level object.
func object_xform(dot: String) -> Transform3D:
	var n := object_node(dot) as Node3D
	if n == null:
		return Transform3D.IDENTITY
	return _objects.scene_xform(n)


## Live preview: put an object at scene-space `t` without journalling (tools; cancel
## puts the start back the same way).
func set_object_xform_live(dot: String, t: Transform3D) -> void:
	var n := object_node(dot) as Node3D
	if n != null:
		n.transform = _objects.local_for(_objects.parent_of(dot), t)


## `dots` minus any whose ancestor is also in `dots`: a selected child already moves
## with its selected parent, and moving it as well would move it twice.
func transform_roots(dots: Array) -> Array:
	if _objects == null or not is_instance_valid(_objects):
		return dots.duplicate()
	var out: Array = []
	for v: Variant in dots:
		var d := String(v)
		var p: String = _objects.parent_of(d)
		var covered := false
		while p != "":
			if dots.has(p):
				covered = true
				break
			p = _objects.parent_of(p)
		if not covered:
			out.append(d)
	return out


## The selected object's effective properties ({} when none is selected).
func object_props(dot: String) -> Dictionary:
	if _objects == null or not is_instance_valid(_objects):
		return {}
	return _objects.props(dot)


## The selected object's dot, or "". Objects and brushes are separate selections
## on purpose: they are moved by different records and a single slot would make
## "what does G move" depend on which was clicked last.
var _sel_object: String = ""
## Every selected object, in the order picked; the last is _sel_object (active).
var _sel_objects: Array[String] = []


## Select a created object by its durable dot. Clears the brush selection, so the
## HUD and G agree about what is selected rather than each reading a different
## slot.
## Emitted (deferred, so the selection is complete) whenever what is selected changes:
## the Outliner follows it, so a click in the scene shows up there.
signal selection_changed


func _announce_selection() -> void:
	if not _sel_announce_queued:
		_sel_announce_queued = true
		_emit_selection.call_deferred()


var _sel_announce_queued := false


func _emit_selection() -> void:
	_sel_announce_queued = false
	_refresh_shape_handles()
	selection_changed.emit()


## SELECT OBJECTS. `add` (Shift-click) toggles `dot` in the selection instead of
## replacing it — the brush rule. The ACTIVE object (_sel_object, what the Object panel
## and the Outliner's fields edit) is the last one added; G / R / Delete act on all.
func select_object(dot: String, add: bool = false) -> void:
	# A PLACED ASSET's object selects the placement (#120 L4): it is not editable alone.
	if dot != "" and _persist != null and String(_persist.instance_of(dot)) != "":
		select_asset(String(_persist.instance_of(dot)))
		return
	if add and dot != "":
		var at := _sel_objects.find(dot)
		if at >= 0:
			_sel_objects.remove_at(at)
		else:
			_sel_objects.append(dot)
		# THE UNION (§17.10): Shift keeps the brushes and curves already held.
		_sel_object = _sel_objects[-1] if not _sel_objects.is_empty() else ""
		_announce_selection()
		_update_face_highlight()
		return
	else:
		_sel_objects.clear()
		if dot != "":
			_sel_objects.append(dot)
	_select_objects_common()


## Replace the object selection with `dots` (the Outliner's multi-row pick); `active`
## becomes the active one when it is among them.
func select_objects(dots: Array, active: String = "") -> void:
	# A placed asset's objects are not selectable alone (#120 L4): dropped, and when they
	# are all there is, their placement is selected instead.
	var placed := ""
	if _persist != null:
		var own: Array = []
		for d0: Variant in dots:
			var pa := String(_persist.instance_of(String(d0)))
			if pa == "":
				own.append(d0)
			elif placed == "":
				placed = pa
		if own.is_empty() and placed != "":
			select_asset(placed)
			return
		dots = own
	_sel_objects.clear()
	for d: Variant in dots:
		if String(d) != "" and not _sel_objects.has(String(d)):
			_sel_objects.append(String(d))
	if active != "" and _sel_objects.has(active):
		_sel_objects.erase(active)
		_sel_objects.append(active)
	_select_objects_common()


func selected_objects() -> Array:
	return _sel_objects.duplicate()


func _select_objects_common() -> void:
	_sel_shape = ""
	_sel_asset = ""
	_sel_curve = ""
	_sel_object = _sel_objects[-1] if not _sel_objects.is_empty() else ""
	_announce_selection()
	_sel_brushes.clear()
	_sel_brush = -1
	_sel_keys = PackedStringArray()
	_sel_kind = "object" if _sel_object != "" else ""
	_update_face_highlight()
	_update_element_highlight()
	_ensure_brush_view().set_aimed(-1)
	print("[MapDCLevel] selected %d object(s), active %s" % [_sel_objects.size(), _sel_object])


## The nearest scene object along the crosshair (or click) ray: {dot, dist, point}.
func _pick_object(at: Variant = null, only_type: String = "") -> Dictionary:
	if _objects == null or not is_instance_valid(_objects):
		return {}
	var ray := _crosshair_ray(at)
	if ray.is_empty():
		return {}
	return _objects.pick(ray["from"], ray["dir"], _aim_reach(), only_type)


## HOW FAR THE AIM REACHES (#109, 2026-10-12: "a distance limit to ray based selection,
## needs to be much bigger" — and "larger ray hits for edit mode ONLY, the carve limits
## in game should stay the same"). PLAY_REACH is the 50 m every aim had since the first
## K-carve commit; play mode and carving keep it. In build mode the reach is the camera's
## far plane: what is drawn can be selected (gate_pick_range).
const PLAY_REACH := 50.0


func _aim_reach() -> float:
	if not GameMode.is_building():
		return PLAY_REACH
	var vp := get_viewport()
	var cam := vp.get_camera_3d() if vp != null else null
	return maxf(cam.far, PLAY_REACH) if cam != null else PLAY_REACH


## The node for a selected dot, or null.
func object_node(dot: String) -> Node:
	if _objects == null or not is_instance_valid(_objects):
		return null
	return _objects.node_for(dot)


## Where a new object should go: on the surface you are aiming at, or a few
## metres in front of you when you are aiming at nothing.
func object_spawn_transform() -> Transform3D:
	var hit: Dictionary = _crosshair_hit()
	if not hit.is_empty():
		return Transform3D(Basis(), hit["position"] as Vector3)
	var ray: Dictionary = _crosshair_ray()
	if ray.is_empty():
		return Transform3D.IDENTITY
	return Transform3D(Basis(), (ray["from"] as Vector3) + (ray["dir"] as Vector3) * 4.0)


func _ensure_persistence() -> void:
	if _persist != null and is_instance_valid(_persist):
		return
	_persist = LevelPersistence.new()
	_persist.name = "LevelPersistence"
	_persist.level = level
	_persist.objects = _ensure_objects()
	_persist.folded.connect(func(st: Dictionary) -> void:
		# The wireframe is every brush's edges: unchanged when the brush half of the fold
		# was skipped (refold's skip rule), and rebuilding it was ~half of an object
		# edit's remaining cost at 1000 brushes.
		if String(st.get("brush_fold", "")) != "skipped":
			_refresh_brush_view()
			_reselect_shape()          # a shape edit renamed its pieces
		# The tree is DERIVED, so an edit changes it: creating an arch adds a
		# clearance volume, deleting a brush empties part of a collection. A panel
		# that rebuilt only when clicked would be showing last import's level.
		# The selected curve went with this fold (undo, a peer's delete): let go of it.
		if _sel_curve != "" and not (_persist.curve_states() as Dictionary).has(_sel_curve):
			_clear_face_selection()
		if _outliner != null and is_instance_valid(_outliner):
			_outliner.refresh()
		_refresh_curves()
		_refresh_shape_handles()             # the selected shape may have a new size
		# Bots must route around what is there NOW (#104 leftover: the nav graph was
		# built once per map entry and never again — bots walked through new walls).
		_queue_nav_rebuild())
	_register_persistence_admin(_persist)
	add_child(_persist)
	# The asset libraries (#120 L4): with the journal, so placed assets' blobs are fetched
	# whatever panel is open.
	_asset_library = AssetLibraryScript.new()
	_asset_library.name = "AssetLibrary"
	add_child(_asset_library)
	_asset_library.setup(self, _persist)


# ---- navigation (bots) --------------------------------------------------------
#
# EVERY LEVEL MAP ROUTES BOTS. This lived in map_flat alone, so the STARTING map
# (map_test, also a map_dc_level) had no nav source at all: the A* brain logged
# "no nav source" and stood still, and bots walked through walls (user, 2026-10-04).
# DCNavVolume is the libdc region/portal graph fed from plain boxes (dc-terrain
# docs/plans/2026-09-15-chunk-portal-nav-design.md §11) -- no density field needed.
#
# The solid set: every axis-aligned BoxShape3D under the map (the scene's own
# colliders and placed objects' bodies) + every solid brush as its world bounds
# (exact for box brushes; a sloped brush is over-approximated -- a bot keeping clear
# of it, never walking into it).

var _nav: Node = null

## Cells per chunk axis and their size. 1 m cells fit a 0.4 m agent radius.
const NAV_CELLS := 16
const NAV_CELL_SIZE := 1.0
## Cap on the graph's cube edge; a larger map overrides _nav_region.
const NAV_MAX_SPAN := 256.0
const NAV_AGENT_RADIUS := 0.4
const NAV_AGENT_HEIGHT := 1.8


func _ensure_nav() -> Node:
	if _nav != null and is_instance_valid(_nav):
		return _nav
	if not ClassDB.class_exists("DCNavVolume"):
		push_warning("[MapDCLevel] DCNavVolume missing — the dc-terrain extension is stale")
		return null
	_nav = ClassDB.instantiate("DCNavVolume")
	_nav.name = "NavVolume"
	add_child(_nav)
	return _nav


## Every BoxShape3D collider under `n`, in world space. Axis-aligned only for now.
func _collect_nav_boxes(n: Node, centres: PackedVector3Array, halves: PackedVector3Array) -> void:
	if n is CollisionShape3D:
		var cs := n as CollisionShape3D
		if cs.shape is BoxShape3D and not cs.disabled:
			var t := cs.global_transform
			var b := t.basis
			var axis_aligned := (
				absf(b.x.normalized().dot(Vector3.RIGHT)) > 0.999
				and absf(b.y.normalized().dot(Vector3.UP)) > 0.999)
			if axis_aligned:
				centres.push_back(t.origin)
				halves.push_back((cs.shape as BoxShape3D).size * 0.5 * b.get_scale())
	for c in n.get_children():
		_collect_nav_boxes(c, centres, halves)


## Every SOLID brush as its world-space bounds. Brush colliders are PhysicsServer
## shapes, not CollisionShape3D nodes, so the walk above never sees them. Sky,
## clearance and hidden brushes are not solid for a walker. Brush vertices are in the
## store's space, which is the DCLevel node's (the brush view is parented to it).
func _collect_brush_boxes(centres: PackedVector3Array, halves: PackedVector3Array) -> void:
	if level == null or not level.has_method("get_live_brush_ids"):
		return
	var skip := {}
	for id in level.get_sky_brush_ids():
		skip[id] = true
	for id in level.get_clearance_brush_ids():
		skip[id] = true
	for id in level.get_hidden_brush_ids():
		skip[id] = true
	var xf: Transform3D = level.global_transform
	var live: PackedInt32Array = level.get_live_brush_ids()
	# ONE CALL FOR EVERY BOX: the store already holds each brush's bounds. This ran after
	# every fold, one get_brush_vertices_of per brush — 22 ms of a 28 ms nav rebuild at 40
	# cables on tabertron (2026-10-20). The bounds are the windings' extent; the vertex
	# walk below took the same over 1e-3-deduplicated corners (_probe_cut_refolds PROBE_NAV
	# compares the two).
	if level.has_method("get_brush_aabbs"):
		var bx: PackedVector3Array = level.get_brush_aabbs(live)
		for i in live.size():
			if skip.has(live[i]):
				continue
			var lo: Vector3 = bx[i * 2]
			var hi: Vector3 = bx[i * 2 + 1]
			if lo.x > hi.x:
				continue                         # no bounds: encloses nothing
			var wb: AABB = xf * AABB(lo, hi - lo)
			centres.push_back(wb.get_center())
			halves.push_back(wb.size * 0.5)
		return
	for id in live:
		if skip.has(id):
			continue
		var vs: PackedVector3Array = level.get_brush_vertices_of(id)
		if vs.is_empty():
			continue
		var bb := AABB(xf * vs[0], Vector3.ZERO)
		for v in vs:
			bb = bb.expand(xf * v)
		centres.push_back(bb.get_center())
		halves.push_back(bb.size * 0.5)


## The box the graph covers: the bounds of everything solid, padded so an agent can
## stand on the highest of it. NOT a cube (DCNavVolume.rebuild_box): a level 200 m
## across and 15 m tall cubed to 208 m measured 3.1 s per build. Each horizontal edge
## is capped at NAV_MAX_SPAN about the centre.
func _nav_region(centres: PackedVector3Array, halves: PackedVector3Array) -> AABB:
	if centres.is_empty():
		return AABB()
	var bb := AABB(centres[0] - halves[0], halves[0] * 2.0)
	for i in range(1, centres.size()):
		bb = bb.merge(AABB(centres[i] - halves[i], halves[i] * 2.0))
	bb = bb.grow(2.0)
	bb.size.y += NAV_AGENT_HEIGHT
	for ax in [0, 2]:
		if bb.size[ax] > NAV_MAX_SPAN:
			push_warning("[MapDCLevel] nav: the map spans %.0f m on axis %d; the graph covers the central %.0f m (override _nav_region)"
					% [bb.size[ax], ax, NAV_MAX_SPAN])
			bb.position[ax] += (bb.size[ax] - NAV_MAX_SPAN) * 0.5
			bb.size[ax] = NAV_MAX_SPAN
	return bb


## Rebuild the graph from what is in the map right now. Returns populated chunks.
## THE NAV GRAPH FOLLOWS THE LEVEL. Every fold (a brush or object edit, an undo, a
## peer's edit arriving) marks it dirty and restarts a NAV_REBUILD_DELAY timer, so a
## burst of edits costs one rebuild after the burst, not one per keypress (~0.1 s at
## 2000 brushes since dc-terrain ad3024f). nav_rebuild clears the flag — the build that
## runs on map entry clears the load's own folds, so a load still builds once.
const NAV_REBUILD_DELAY := 1.0
var _nav_dirty := false
var _nav_timer: Timer = null
var nav_builds := 0


func _queue_nav_rebuild() -> void:
	_nav_dirty = true
	if _nav_timer == null or not is_instance_valid(_nav_timer):
		_nav_timer = Timer.new()
		_nav_timer.name = "NavRebuildTimer"
		_nav_timer.one_shot = true
		_nav_timer.timeout.connect(func() -> void:
			if _nav_dirty:
				nav_rebuild())
		add_child(_nav_timer)
	if _nav_timer.is_inside_tree():
		_nav_timer.start(NAV_REBUILD_DELAY)


func nav_rebuild() -> int:
	_nav_dirty = false
	nav_builds += 1
	var v := _ensure_nav()
	if v == null:
		return 0
	var centres := PackedVector3Array()
	var halves := PackedVector3Array()
	_collect_nav_boxes(self, centres, halves)
	var node_boxes := centres.size()
	_collect_brush_boxes(centres, halves)
	var region := _nav_region(centres, halves)
	if region.size == Vector3.ZERO:
		return 0
	v.set_boxes(centres, halves)
	var populated: int = v.rebuild_box(region, NAV_CELLS * NAV_CELL_SIZE, NAV_CELLS,
			NAV_AGENT_RADIUS, NAV_AGENT_HEIGHT)
	var st: Dictionary = v.stats()
	print("[MapDCLevel] nav: %d boxes + %d brushes over %s m -> %d chunks, %d links, %d regions, %d standable"
			% [node_boxes, centres.size() - node_boxes, str(region.size.round()), populated,
			   int(st["links"]), int(st["regions"]), int(st["standable"])])
	# Durably: print() goes to stdout, which a dedicated peer's TUI swallows.
	if GameState != null:
		GameState.set_state("state:map:nav:boxes", centres.size())
		GameState.set_state("state:map:nav:chunks", populated)
		GameState.set_state("state:map:nav:links", int(st["links"]))
		GameState.set_state("state:map:nav:standable", int(st["standable"]))
	return populated


func get_nav_volume() -> Node:
	return _nav


## Expose the level journal on the AdminFacade so a builder bot -- or the TUI, or
## admin-cli -- can append, inspect and roll back edits. This is `bot.edit` from
## spec/bot-testbed.md (e3) with nothing invented: record/undo_op/records are the
## existing API, and the operator stack is already the vocabulary.
##
## REGISTERED HERE, NOT IN level_persistence.gd's _ready, so only the LIVE
## instance is a control surface. Gates build bare LevelPersistence nodes as
## fixtures (level_journal_roundtrip_gate.gd makes two), and registering in _ready
## would put test fixtures in the admin_controllable group and collide their
## facade paths.
##
## SCOPE IS MAP (the default, not "global"). The journal belongs to the level it
## journals, and it is a child of this map, so it dies with the map -- which is
## the containment rule from (e1) rather than an accident.
##
## ⚠ EVERY SCOPE IS "authority", AND HERE THAT IS LOAD-BEARING. For
## PerfMetrics.snapshot, "local" merely returned an arbitrary peer's numbers
## because results share one admin:result:<seq> key. For the journal it is far
## worse: an admin:cmd reaches EVERY peer, records are AUTHOR-SCOPED, and each
## peer would reserve its own dot -- so one "create a box" command would append N
## separate records under N authors and build the same brush N times. "authority"
## means one peer records it, which is also exactly the "the room owns a builder
## bot's edits" model: the edit is authored by whoever holds authority.
##
## Roles split read from write. Reads are "peer" -- inspecting the level you are
## standing in is not privileged. Writes are "mod": a room-owned builder bot is
## privileged world authoring, the same judgement world:creatures:registry: makes
## by carrying role mod.
##
## `by_bot` is a RESERVED PAYLOAD KEY, not a parameter. record_at() does
## payload.duplicate() and adds only v/kind/undone/src, so an extra key is stored
## verbatim -- no change to LevelPersistence is needed. records() can then filter
## on it, and undo_op(dot) rolls back one record, which together give
## bulk-undo-of-a-bot's-work with no new mechanism. Until the bot registry exists
## the caller supplies it; the registry will stamp it.
func _register_persistence_admin(p: Node) -> void:
	p.add_to_group("admin_controllable")
	p.set_meta("admin_name", "level")
	p.set_meta("admin_exposed_methods", [
		"record", "undo_op", "redo_op", "undo_last", "redo_last",
		"records", "undo_depth", "redo_depth",
	])
	p.set_meta("admin_call_scopes", {
		"record": "authority", "undo_op": "authority", "redo_op": "authority",
		"undo_last": "authority", "redo_last": "authority",
		"records": "authority", "undo_depth": "authority", "redo_depth": "authority",
	})
	p.set_meta("admin_roles", {
		"records": {"call": "peer"},
		"undo_depth": {"call": "peer"},
		"redo_depth": {"call": "peer"},
		"record": {"call": "mod"},
		"undo_op": {"call": "mod"},
		"redo_op": {"call": "mod"},
		"undo_last": {"call": "mod"},
		"redo_last": {"call": "mod"},
	})

## ONE KEY = ONE GESTURE (editor redesign E5). Whatever a builder key writes — a CSG op's
## records, a texture on six faces, an operator's confirm — shares one gesture id, so one
## Ctrl+Z takes it back. A key that writes nothing makes no gesture (the id is minted by the
## first record). Undo itself writes tombstones, not records, so it is not a gesture.
func _unhandled_key_input(event: InputEvent) -> void:
	if level == null or not (event is InputEventKey) or event.echo:
		return
	var p: Node = _persist if _persist != null and is_instance_valid(_persist) else null
	if p != null:
		p.begin_gesture()
	_builder_key(event)
	if p != null:
		p.end_gesture()


func _builder_key(event: InputEvent) -> void:
	# A key, or a KEYLESS action from Keymap.run (an InputEventAction: the Q menu's
	# rows, Subtract since §9c) — see _unhandled_input.
	if level == null or not (event is InputEventKey or event is InputEventAction) \
			or (event is InputEventKey and event.echo):
		return
	# Box select (B) is waiting for a drag: Esc cancels it.
	if _box_sel and event is InputEventKey and (event as InputEventKey).keycode == KEY_ESCAPE:
		_box_sel = false
		_box_show(Rect2())
		get_viewport().set_input_as_handled()
		return
	# Circle select (C): Esc / Enter leave it; [ ] resize the ring.
	if _circ_sel and event is InputEventKey and (event as InputEventKey).pressed:
		var ck: int = KeyRules.key_of(event as InputEventKey)
		if ck in [KEY_ESCAPE, KEY_ENTER, KEY_KP_ENTER]:
			end_circle_select()
			get_viewport().set_input_as_handled()
			return
		if ck == KEY_BRACKETRIGHT or ck == KEY_BRACKETLEFT:
			_circ_radius = clampf(_circ_radius * (1.15 if ck == KEY_BRACKETRIGHT else 1.0 / 1.15), CIRC_MIN, CIRC_MAX)
			_circ_show()
			get_viewport().set_input_as_handled()
			return
	_note_repeatable(event)             # Shift+R repeats what was last started here
	# EVERY BINDING HERE IS A NAMED ACTION IN THE `builder` CONTEXT (autoloads/
	# keymap.gd), not a keycode comparison. Three things that buys, all of which
	# the keymap audit found the hard way:
	#   - the bindings are ENUMERABLE, so a conflict is reportable instead of
	#     something you grep for by hand and get wrong (Ctrl+Z shipped on top of
	#     toggle_slowmo, which is bound to plain Z);
	#   - Keymap.tapped matches EXACTLY, so an action on a plain key cannot fire
	#     when a modifier is held — Godot's default does the opposite;
	#   - they are rebindable and context-scoped, which is what lets `B` mean one
	#     thing while building and another while walking.
	_ensure_persistence()

	# THE BOT EDITOR has no brush tools: their keys do nothing there (G / R / Delete /
	# undo / select keep working — on the selected bots).
	if in_bot_editor():
		for a: String in LEVEL_ONLY_ACTIONS:
			if Keymap.tapped(event, a):
				return

	# THE ACTIVE OPERATOR GETS FIRST REFUSAL — which is what this always claimed and
	# was not. It sat BELOW the selection nudge, and `[` / `]` are bound there
	# (builder.move_in / move_out), so those two keys were consumed and returned
	# before any operator saw them: the Knife's offset, Move's steps, Extrude's push
	# and Box's resize were ALL dead in the running game. Every one of them was gated
	# by calling handle_key directly, which is the seam issue #23 named — the gate
	# proved the operator does the right thing when told, never that it gets told.
	#
	# Above everything, not merely above the nudge: while a tool is up its keys are
	# ITS keys, and that is what makes it modal. Anything it does not claim still
	# falls through — including movement, or flying with the knife out would strand
	# you — and the same keys mean the selection nudge again the moment it is gone.
	var ops := _ensure_ops()
	if ops.handle_key(event):
		_update_op_hud()
		# AND NOTHING ELSE GETS IT. Nothing here marked input handled, so a key the
		# map acted on ALSO reached the player's _unhandled_input — which is why the
		# Knife's `X` axis key toggles the flashlight (toggle_light is bound to X).
		# Same class as Ctrl+Z also firing toggle_slowmo, and the same fix: claimed
		# means claimed.
		var vp := get_viewport()
		if vp != null:
			vp.set_input_as_handled()
		return
	if event is InputEventKey and (event as InputEventKey).keycode == KEY_ESCAPE and ops.handle_escape():
		# First-wins: an active operator eats Escape; otherwise it goes on to mean
		# release-mouse / menu exactly as it always did.
		#
		# MARKED HANDLED, which it was not: the player releases the mouse on
		# `ui_cancel` from its own _unhandled_input, so cancelling a tool ALSO threw
		# you out of mouse capture. First-wins means nothing else gets it.
		print("[MapDCLevel] operator cancelled — nothing was written")
		_update_op_hud()
		var esc_vp := get_viewport()
		if esc_vp != null:
			esc_vp.set_input_as_handled()
		return
	if Keymap.tapped(event, "builder.confirm"):
		ops.confirm()
		return
	if Keymap.tapped(event, "builder.tool_popup") and _tool_popup != null:
		var tools: Array = []
		for t: Array in load("res://scenes/game/editor_tool_bar.gd").TOOLS:
			tools.append([t[0], t[1], t[2]])
		var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
		_tool_popup.open_sections([["Tools", tools]], at)
		var tp_vp := get_viewport()
		if tp_vp != null:
			tp_vp.set_input_as_handled()
		return
	# SHIFT+S = THE SNAP MENU, WHEN NOT MOVING (#109, user's choice: Shift+S is also fast
	# fly-back). The S of this press is the menu's; any OTHER movement key already held
	# means you are flying, and then Shift+S stays fly-back.
	if Keymap.tapped(event, "builder.q_menu") and _q_menu != null:
		var qat := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
		_q_menu.open_sections(q_sections(), qat)
		var q_vp := get_viewport()
		if q_vp != null:
			q_vp.set_input_as_handled()
		return
	for hp: Array in [["builder.hide", "hide_selection"], ["builder.hide_unselected", "hide_unselected"],
			["builder.unhide_all", "unhide_all"], ["builder.make_real", "_q_make_real"],
			["builder.cuts_clear", "clear_cuts"], ["builder.select_cut_target", "select_cut_target"],
			["builder.extrude_make_real", "_q_extrude_make_real"], ["builder.prompt_cancel", "_q_prompt_cancel"],
			["builder.make_real_then", "_q_make_real_then"], ["builder.place_asset", "place_current_asset"]]:
		if Keymap.tapped(event, String(hp[0])):
			call(String(hp[1]))
			return
	for kp: Array in [["builder.cut_as_subtract", "subtract"], ["builder.cut_as_intersect", "intersect"],
			["builder.cut_as_slice", "slice"]]:
		if Keymap.tapped(event, String(kp[0])):
			set_cutter_kind(stack_subject(), String(kp[1]))
			return
	if Keymap.tapped(event, "builder.cursor_menu") and _context_menu != null and not _moving_except_back():
		var at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
		_context_menu.open_sections([cursor_section()], at)
		var cm_vp := get_viewport()
		if cm_vp != null:
			cm_vp.set_input_as_handled()
		return
	if Keymap.tapped(event, "builder.palette") and _palette != null:
		_palette.open()
		var pal_vp := get_viewport()
		if pal_vp != null:
			pal_vp.set_input_as_handled()
		return

	# Fill is the one HELD interaction — aim, hold, aim, release — so it needs the
	# key-up that every other binding discards.
	if Keymap.tapped(event, "builder.fill"):
		_fill_begin()
		return
	if Keymap.released(event, "builder.fill"):
		_fill_commit()
		return

	# Universal undo / redo, over every edit I authored in this room and map,
	# including previous sessions: it queries the journal rather than a stack.
	if Keymap.tapped(event, "builder.redo"):
		_undo_redo(true)
		return
	if Keymap.tapped(event, "builder.undo"):
		_undo_redo(false)
		return

	# Face / edge / vertex editing. Separate keys from carving because they are
	# different operations on different things: a carve leaves the brush
	# authoritative and derives a voxel pocket, a nudge MUTATES the brush and
	# produces no model at all. Conflating them would hide the architecture's fork.
	if Keymap.tapped(event, "builder.deselect"):
		_clear_face_selection()
		return
	if Keymap.tapped(event, "builder.select"):
		_pick_element()
		return
	# ---- KEYMAP STEP 3 (docs/keymap-blender-279.md §3a) ----
	if Keymap.tapped(event, "builder.select_all"):
		# 2.79's A with use_select_all_toggle: anything selected -> none, else ALL —
		# objects, brushes and curves together (§17.10).
		select_all()
		return
	if Keymap.tapped(event, "builder.select_invert"):
		select_invert()
		return
	if Keymap.tapped(event, "builder.select_box"):
		begin_box_select()
		return
	if Keymap.tapped(event, "builder.select_circle"):
		begin_circle_select()
		return
	# Ctrl+Tab: Blender's select-mode menu — the same actions as 1 2 3 4, keys shown.
	if Keymap.tapped(event, "builder.select_mode_menu") and _q_menu != null:
		var sm_at := get_viewport().get_mouse_position() if get_viewport() != null else Vector2.ZERO
		_q_menu.open_sections([["Select mode", [["build_mode.sel_vertex"], ["build_mode.sel_edge"],
			["build_mode.sel_face"], ["build_mode.sel_brush"]]]], sm_at)
		var sm_vp := get_viewport()
		if sm_vp != null:
			sm_vp.set_input_as_handled()
		return
	if Keymap.tapped(event, "builder.select_linked_pick"):
		select_linked_pick(false)
		return
	if Keymap.tapped(event, "builder.deselect_linked_pick"):
		select_linked_pick(true)
		return
	if Keymap.tapped(event, "builder.select_linked"):
		select_linked()
		return
	if Keymap.tapped(event, "builder.delete_menu") and _q_menu != null:
		_q_menu.open_sections(delete_sections(), get_viewport().get_mouse_position())
		get_viewport().set_input_as_handled()
		return
	if Keymap.tapped(event, "builder.select_grouped") and _q_menu != null:
		_q_menu.open_sections(grouped_sections(), get_viewport().get_mouse_position())
		get_viewport().set_input_as_handled()
		return
	for sg: Array in [["builder.select_same_group", "group"], ["builder.select_same_material", "material"],
			["builder.select_same_texture", "texture"], ["builder.select_same_type", "type"]]:
		if Keymap.tapped(event, String(sg[0])):
			select_grouped(String(sg[1]))
			return
	if Keymap.tapped(event, "builder.add_menu") and _q_menu != null:
		_q_menu.open_sections(add_sections(), get_viewport().get_mouse_position())
		get_viewport().set_input_as_handled()
		return
	for shape_id: String in ADD_SHAPES:
		if Keymap.tapped(event, "builder.add_%s" % shape_id):
			start_create(shape_id)
			return
	if Keymap.tapped(event, "builder.uv_menu") and _q_menu != null:
		_q_menu.open_sections(uv_sections(), get_viewport().get_mouse_position())
		get_viewport().set_input_as_handled()
		return
	if Keymap.tapped(event, "builder.toggle_sidebar") or Keymap.tapped(event, "builder.toggle_toolbar"):
		var side: String = BuildDock.SIDE_RIGHT if Keymap.tapped(event, "builder.toggle_sidebar") else BuildDock.SIDE_LEFT
		BuildDock.set_region_collapsed(side, not BuildDock.is_region_collapsed(side))
		return
	if Keymap.tapped(event, "builder.reset_panels"):
		BuildDock.reset_panel_layout()
		return
	if Keymap.tapped(event, "builder.adjust_last") or Keymap.tapped(event, "builder.history"):
		var pid := "adjust_last" if Keymap.tapped(event, "builder.adjust_last") else "history"
		if BuildDock.has_panel(pid):
			BuildDock._toggle(pid)
		else:
			print("[MapDCLevel] %s: nothing to show yet" % pid)
		return
	if Keymap.tapped(event, "builder.repeat_last"):
		if _last_repeatable == "":
			print("[MapDCLevel] repeat last: nothing to repeat yet")
		else:
			# BoxCutter repeats with the last cut's modifiers (#120 L2 §13.5).
			var bcut := get_node_or_null("BoxCutter")
			if _last_repeatable == "builder.box_cutter" and bcut != null:
				bcut.restore_last_queue()
			Keymap.run(_last_repeatable)
		return
	if Keymap.tapped(event, "builder.element_toggle"):
		set_select_mode(_last_element_mode if _sel_mode == "brush" else "brush")
		return
	if Keymap.tapped(event, "builder.wireframe"):
		toggle_wireframe()
		return
	for pv: Array in [["builder.pivot_bbox", "bbox"], ["builder.pivot_median", "median"],
			["builder.pivot_cursor", "cursor"], ["builder.pivot_active", "active"],
			["builder.pivot_individual", "individual"]]:
		if Keymap.tapped(event, String(pv[0])):
			set_pivot_mode(String(pv[1]))
			return
	if Keymap.tapped(event, "builder.proportional"):
		toggle_proportional()
		return
	if Keymap.tapped(event, "builder.proportional_falloff"):
		cycle_prop_falloff()
		return
	if Keymap.tapped(event, "builder.proportional_connected"):
		toggle_prop_connected()
		return
	if Keymap.tapped(event, "builder.texture_apply"):
		apply_texture_to_selection()
		return
	if Keymap.tapped(event, "builder.texture_pick"):
		eyedrop_texture()
		return
	if _uv_key(event):
		return
	if Keymap.tapped(event, "builder.move_in"):
		_nudge_element(-_grid_step())
		return
	if Keymap.tapped(event, "builder.move_out"):
		_nudge_element(_grid_step())
		return
	for pair: Array in [["subtract", "build_mode.csg_subtract"],
						["intersect", "build_mode.csg_intersect"],
						["slice", "build_mode.csg_slice"],
						["merge", "build_mode.csg_merge"]]:
		if Keymap.tapped(event, String(pair[1])):
			_csg(String(pair[0]))
			return
	if Keymap.tapped(event, "build_mode.csg_discard"):
		csg_discard = not csg_discard
		print("[csg] cutters will be %s" % ("DISCARDED" if csg_discard else "KEPT"))
		return
	if Keymap.tapped(event, "build_mode.faces_hull"):
		faces_to_hull()
		return
	if Keymap.tapped(event, "build_mode.faces_extrude"):
		faces_extrude()
		return

	for mode: String in SEL_MODES:
		if not Keymap.tapped(event, "build_mode.sel_%s" % mode):
			continue
		set_select_mode(mode)
		return

	if Keymap.tapped(event, "builder.grid_finer") or Keymap.tapped(event, "builder.grid_coarser"):
		# NOT the number row: 1-9 are weapon slots in a shooter. TrenchBroom binds
		# grid sizes to 1-9 and can afford to; this cannot.
		var finer: bool = Keymap.tapped(event, "builder.grid_finer")
		_grid_idx = clampi(_grid_idx + (-1 if finer else 1), 0, GRID_UNITS.size() - 1)   # GRID_UNITS ascends
		print("[MapDCLevel] grid %s (%.3f m)" % [fmt_units(base_grid_units()), base_grid_units() * WORLD_SCALE])
		return
	if Keymap.tapped(event, "builder.snap_toggle"):
		snap_enabled = not snap_enabled
		print("[MapDCLevel] snap %s" % ("on" if snap_enabled else "off — 1 unit steps"))
		return

	# G MOVES THE SELECTION, which is Blender's rule and needs no second key: a
	# selected vertex, edge or face if there is one, otherwise the whole brush under
	# the crosshair. Both drag with your aim; they differ only in what moves.
	# R TURNS THE SELECTION (or the aimed brush) — Blender's key for rotate.
	if Keymap.tapped(event, "builder.rotate"):
		if ops.activate(_rotate):
			print("[MapDCLevel] rotate: X Y Z axis, [ ] or wheel turns, Enter to rotate")
		return
	# Ctrl+P parents the selected objects to the ACTIVE one; Alt+P clears (Blender).
	if Keymap.tapped(event, "builder.parent"):
		var others := selected_objects()
		others.erase(_sel_object)
		if _sel_object == "" or others.is_empty():
			print("[MapDCLevel] parent: select the children, then the parent last (it is the active one)")
		else:
			parent_objects(others, _sel_object)
		return
	if Keymap.tapped(event, "builder.clear_parent"):
		if selected_objects().is_empty():
			print("[MapDCLevel] clear parent: select objects first")
		else:
			parent_objects(selected_objects(), "")
		return
	# OBJECTS AND COLLECTIONS (#120 F1): Ctrl+G, M, Shift+M, Ctrl+Alt+G, Ctrl+J, P.
	if _collection_keys(event):
		return
	# O SCALES the selection (or the aimed brush, or the selected models) — not S, which
	# flies backwards in build mode.
	if Keymap.tapped(event, "builder.scale"):
		if ops.activate(_scale):
			print("[MapDCLevel] scale: [ ] or wheel, X Y Z axis, C pivot, Enter to scale")
		return
	if Keymap.tapped(event, "builder.mirror"):
		if ops.activate(_mirror):
			print("[MapDCLevel] mirror: X Y Z axis, Enter to mirror, Esc to cancel")
		return
	if Keymap.tapped(event, "builder.duplicate"):
		duplicate_selection()
		return
	if Keymap.tapped(event, "builder.duplicate_linked"):
		duplicate_selection(true)
		return
	if Keymap.tapped(event, "builder.move"):
		var g: LevelOperator = _move
		var what := "brush under the crosshair"
		# A SELECTED CURVE by select mode (§17.7 Q4): vertex mode edits the point / handle
		# end nearest the pointer (Edit curve keeps to the selected curve); any other mode
		# moves the whole curve (Move's curve branch).
		if _sel_curve != "" and _sel_mode == "vertex":
			g = _curve_edit
			what = "curve point"
		elif _sel_curve != "":
			what = "selected curve"
		elif _sel_brush >= 0 and _sel_kind == "brush":
			what = "selected brush"
		elif _sel_brush >= 0 and not _sel_keys.is_empty():
			g = _grab_op
			what = "selected %s" % _sel_kind
			# A shape's part: Make Real first (or Cancel), then the grab on the same part.
			if not guard_shape_part("move", func() -> void: _ensure_ops().activate(_grab_op)):
				return
		if ops.activate(g):
			print("[MapDCLevel] %s the %s: look to drag, X/Y/Z axis, [ ] to nudge, Enter, Esc to cancel"
				% [g.op_name(), what])
		return

	# One line per operator, because that is all an operator should cost to add.
	for binding: Array in [
			["builder.knife", _knife, "aim or place points with P, X/Y/Z axis, [ ] offset, Enter to cut"],
			["builder.extrude", _extrude, "[ ] to push the face, Enter to extrude"],
			["builder.create_box", _box, "[ ] to resize, Enter to create"],
			["builder.box_cutter", get_node_or_null("BoxCutter"), "P corner, X mode, E depth, Space through, Enter to cut"]]:
		if not Keymap.tapped(event, String(binding[0])):
			continue
		if ops.activate(binding[1] as LevelOperator):
			print("[MapDCLevel] %s: %s, Esc to cancel"
				% [(binding[1] as LevelOperator).op_name(), String(binding[2])])
		return

	# OVERLAYS (keymap step 6.5): each key flips its overlay's own switch; Shift+Alt+Z the
	# master. Counts printed by toggle_overlay.
	for ov: String in OVERLAYS:
		if Keymap.tapped(event, String((OVERLAYS[ov] as Array)[0])):
			toggle_overlay(ov)
			return
	if Keymap.tapped(event, "builder.overlays_all"):
		set_overlays_all(not overlays_all_on())
		print("[MapDCLevel] overlays %s" % ("back on" if overlays_all_on() else "all off"))
		return
	if Keymap.tapped(event, "builder.delete"):
		# The SELECTION — objects, then brushes; only with nothing selected the aimed
		# brush (delete_selection).
		delete_selection()
		return
	if Keymap.tapped(event, "builder.add_curve"):
		if _ensure_ops().activate(_curve_op):
			print("[MapDCLevel] curve: P places a point on the aimed surface, C closes, Enter adds it")
		return
	if Keymap.tapped(event, "builder.add_cable"):
		if _ensure_ops().activate(_cable_op):
			print("[MapDCLevel] cable: P places a point on the aimed surface, [ ] slack, C closes, Enter adds it")
		return
	if Keymap.tapped(event, "builder.edit_curve"):
		if _ensure_ops().activate(_curve_edit):
			print("[MapDCLevel] curve edit: the point follows your aim, Tab next, X delete, I insert, Enter applies")
		return
	if Keymap.tapped(event, "builder.cable_bundle"):
		if _ensure_ops().activate(_bundle_op):
			print("[MapDCLevel] cable bundle: Z count, D tension, N jitter, S radius, R ends, Q seed, Enter adds")
		return
	if Keymap.tapped(event, "builder.cable_from_edges"):
		if _ensure_ops().activate(_edges_op):
			print("[MapDCLevel] cable from edges: P vertices / centres, C corners, [ ] offset, Enter adds")
		return
	if Keymap.tapped(event, "builder.drop_cable"):
		if _ensure_ops().activate(_drop_op):
			print("[MapDCLevel] drop: the selected cables fall to the ground, [ ] point step, Enter drops")
		return
	if Keymap.tapped(event, "builder.make_parametric"):
		make_parametric()
		return
	if Keymap.tapped(event, "builder.add_modifier"):
		if _sel_shape == "":
			print("[MapDCLevel] add modifier: select a shape (Make parametric turns a brush into one)")
		elif _q_menu != null:
			var rows: Array = []
			for k: String in MOD_DEFAULTS:
				rows.append([k.capitalize(), func() -> void: add_modifier(k), MOD_KEYS.get(k, 0)])
			_q_menu.open_choices("Add modifier", rows, get_viewport().get_mouse_position())
		return
	if Keymap.tapped(event, "builder.stack_scroll_back") or Keymap.tapped(event, "builder.stack_scroll_fwd"):
		preload("res://scenes/maps/stack_ops.gd").scroll(self, -1 if Keymap.tapped(event, "builder.stack_scroll_back") else 1)
		return
	# #120 L2 §13.4–13.5: Change cut / Extract / Apply up to here — each picks one of the
	# subject's cuts from a list (StackOps).
	for sa: Array in [["builder.change_cut", "Change cut"], ["builder.extract_cut", "Extract"],
			["builder.apply_up_to", "Apply up to here"]]:
		if not Keymap.tapped(event, String(sa[0])):
			continue
		var so := preload("res://scenes/maps/stack_ops.gd")
		var subj := stack_subject()
		var srows: Array = []
		match String(sa[0]):
			"builder.change_cut":
				srows = so.change_rows(self, subj)
			"builder.extract_cut":
				srows = so.op_rows(self, subj, so.extract)
			_:
				srows = so.op_rows(self, subj, so.apply_up_to)
		if srows.is_empty():
			print("[MapDCLevel] %s: the selection has no cuts" % String(sa[1]).to_lower())
		elif _q_menu != null:
			_q_menu.open_choices(String(sa[1]), srows, get_viewport().get_mouse_position())
		return
	if Keymap.tapped(event, "builder.assign_generator"):
		if _sel_shape == "":
			print("[MapDCLevel] assign generator: select a shape (Make parametric turns a brush into one)")
		elif _q_menu != null:
			_q_menu.open_choices("Generator", generator_choices(), get_viewport().get_mouse_position())
		return
	if Keymap.tapped(event, "builder.delete_hierarchy"):
		if selected_objects().is_empty():
			print("[MapDCLevel] delete hierarchy: select objects first")
		else:
			delete_objects(selected_objects(), true)
		return

	if not Keymap.tapped(event, "builder.carve"):
		return

	var hit := _crosshair_hit(null, PLAY_REACH)      # carving keeps its reach (#109)
	if hit.is_empty():
		print("[MapDCLevel] carve: nothing under the crosshair")
		return
	# Name what was hit. "Nothing happened" with no indication of what the ray
	# found is not a diagnosis, and this is the line that would have identified
	# the bug above in one keypress instead of a deploy round-trip.
	var who: Object = hit.get("collider")
	var who_name: String = who.get_class() if who != null else "<null>"
	if who is Node:
		who_name = "%s (%s)" % [(who as Node).name, who.get_class()]

	var before: int = int(level.get_stats().get("triangles", 0))
	# The DOT FIRST, then the local apply with that same name. The optimistic local
	# state and the journal then agree on identity from the start, so the fold that
	# follows is a no-op rather than a re-application under a different id.
	var pos: Vector3 = hit["position"]
	var dot: String = _persist.record("carve", {
		"type": "sphere",
		"pos": [pos.x, pos.y, pos.z],
		"radius": 0.6,
	})
	var id: int = level.apply_carve_sphere(pos, 0.6, dot)
	if id < 0:
		return
	level.rebuild()
	var stats: Dictionary = level.get_stats()
	print("[MapDCLevel] carve %s (local %d) at %s  hit=%s -> %s" % [dot, id, str(pos), who_name, str(stats)])
	# A carve that touches no brush is a silent no-op by construction: the journal
	# accepts it, nothing leaves the clean view, no pocket is built. Say so.
	if int(stats.get("triangles", 0)) == before and int(stats.get("pockets", 0)) == 0:
		push_warning("[MapDCLevel] carve %d touched NO brush — the hit at %s is not inside any brush AABB (hit %s). Nothing was carved." % [id, str(hit["position"]), who_name])


## MOUSE CONFIRM AND CANCEL, which is how every editor ends a modal transform:
## Blender's G is finished with a left click and abandoned with a right one, and
## TrenchBroom's clip is the same. Enter and Escape still work and always did — this
## is the gesture your hand is already making, on the device that is doing the
## dragging.
##
## ONLY WHILE A TOOL IS UP. With no operator active both buttons mean exactly what
## they meant before, so nothing is taken away from play. And a click that IS
## consumed here is marked handled, so it cannot also reach the player — which used
## to fire the grapple, because left click is `grapple` (that is now context-gated at
## its source as well; both halves are needed, and neither is sufficient alone).
func _unhandled_input(event: InputEvent) -> void:
	# THE BASE CLASS FIRST. This override used to swallow the event entirely, so
	# every universal key map_core.gd defines -- F6 wireframe (documented there
	# as being "on EVERY map"), the Ctrl+F5 ground probe, Ctrl+F8 edit overlay,
	# Ctrl+F9 LOD colours -- silently did nothing on this map. An override that
	# does not call super is how a base class's feature disappears from exactly
	# the maps that need it most.
	super._unhandled_input(event)

	# KEYLESS ACTIONS. Keymap.run hands an action with no key (the Q menu's rows, CSG
	# Subtract since docs/cutting-and-operations.md §9c) over as an InputEventAction,
	# and _unhandled_key_input never sees one — so a menu pick of it did nothing.
	# The same gesture wrap as a key: one pick, one Ctrl+Z.
	if level != null and event is InputEventAction and event.is_pressed():
		var gp: Node = _persist if _persist != null and is_instance_valid(_persist) else null
		if gp != null:
			gp.begin_gesture()
		_builder_key(event)
		if gp != null:
			gp.end_gesture()
		return

	# BOX SELECT (B) owns the next LMB drag (and motion while dragging).
	if level != null and _box_select_input(event):
		get_viewport().set_input_as_handled()
		return
	# CIRCLE SELECT (C) owns the mouse while it is up: paint, resize, leave.
	if level != null and _circle_select_input(event):
		get_viewport().set_input_as_handled()
		return

	if level == null or not (event is InputEventMouseButton):
		return
	var mb := event as InputEventMouseButton
	if not mb.pressed:
		return
	# A click ON THE DOCK is the dock's, even where its containers let it through (#109).
	if BuildDock != null and BuildDock.covers(mb.position):
		return
	var ops := _ensure_ops()
	# THE WHEEL DRIVES THE ACTIVE TOOL (user decision 2026-10-02): handed to it as its own
	# ] / [ key, so every tool's size / offset / steps follows the wheel with no per-tool
	# code. Cursor out only — while looking (RMB held) the wheel is the camera's, and
	# over a panel the GUI scrolls it before this ever sees it.
	if ops.active != null:
		_tool_mouse(mb)
		return
	if ops.active == null:
		# CLICK TO SELECT, which is how every editor picks and how you asked for it.
		# `B` still works; this is the gesture your hand is already making. Only while
		# BUILDING: the builder context is additive, so it is live during play on this
		# map too, and there a left click is the grapple.
		if mb.button_index != MOUSE_BUTTON_LEFT or not GameMode.is_building():
			return
		_ensure_persistence()
		# A SELECTED SHAPE'S FACE HANDLE under the click starts its drag (§17.7 Q3a); the
		# release writes it. Shift-click still extends the selection.
		if mb.pressed and not mb.shift_pressed and press_shape_handle(mb.position):
			var hvp := get_viewport()
			if hvp != null:
				hvp.set_input_as_handled()
			return
		# SHIFT EXTENDS. Blender, TrenchBroom and every editor in between; the one
		# gesture nobody has to be taught.
		#
		# Picked at the position the CLICK carries, not wherever the pointer has got
		# to by now. They agree in the ordinary case and the event is the authority
		# in every other one.
		_pick_element(mb.shift_pressed, mb.position)
		var pick_vp := get_viewport()
		if pick_vp != null:
			pick_vp.set_input_as_handled()
		return


## A RUNNING TOOL OWNS THE MOUSE (#124; user 2026-10-18: "when doing a mouse move with g
## it seems i can still select things in the panels? that should be an override since i
## expect the mouse travel to not have other panels interactive"). Blender's modal
## operators take every event. While a tool is up in build mode with the cursor out,
## every mouse event is taken HERE, before the GUI: no panel hovers, scrolls or clicks
## under the travelling pointer, and the buttons and wheel go to the tool as they would
## over the 3D view. The tool reads the pointer itself each frame (_crosshair_ray), so
## consuming the motion costs it nothing. Looking (RMB held) captures the pointer and is
## not this path.
func _input(event: InputEvent) -> void:
	if level == null or _ops == null or not is_instance_valid(_ops) or _ops.active == null:
		return
	if not (event is InputEventMouse) or not GameMode.is_building() or not GameMode.wants_pointer():
		return
	if event is InputEventMouseButton and (event as InputEventMouseButton).pressed:
		_tool_mouse(event as InputEventMouseButton)
	elif event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_LEFT:
		# A GIZMO DRAG (a face handle, §17.7 Q3a) writes on RELEASE; one that moved nothing
		# is let go.
		var rop: Object = _ops.active
		if rop != null and (rop.modal() as ModalControls).grabbing():
			(rop.modal() as ModalControls).release_handle()     # a held dot lets go (§17.9)
		elif rop != null and rop.has_method("confirms_on_release") and bool(rop.confirms_on_release()):
			if not _ops.confirm():
				_ops.cancel()
	elif event is InputEventMouseMotion:
		var hop: Object = _ops.active
		if hop != null and (hop.modal() as ModalControls).grabbing():
			(hop.modal() as ModalControls).drag_handle(_crosshair_ray())
			_ops.update_preview()
		else:
			_tool_drag(event as InputEventMouseMotion)
	var vp := get_viewport()
	if vp != null:
		vp.set_input_as_handled()


## MOUSE DRAG OF THE FOCUSED VALUE (§17.7 Q2) for a tool that drags_value(): the change in
## the VIRTUAL pointer's x (mouse + wrap offset, so a wrap at the view's edge never jumps
## the value), Shift finer, Ctrl snapped (ModalControls.drag).
var _drag_x: Variant = null
var _drag_op: Object = null


func _tool_drag(mm: InputEventMouseMotion) -> void:
	var op: Object = _ops.active
	if op == null or not op.has_method("drags_value") or not bool(op.drags_value()):
		_drag_x = null
		return
	var x: float = _pointer_pos(mm.position).x + _wrap_offset.x
	if op != _drag_op or _drag_x == null:
		_drag_op = op
		_drag_x = x
		return
	var dx: float = x - float(_drag_x)
	_drag_x = x
	if (op.modal() as ModalControls).drag(dx, mm.shift_pressed, mm.ctrl_pressed or mm.meta_pressed):
		_ops.update_preview()


## The panel shield (BuildDock.set_modal) is up exactly when _input owns the mouse: a tool
## is up AND the pointer is free. While looking (RMB held) the pointer is captured, the GUI
## still gets clicks at the screen centre, and a shield there would swallow the tool's LMB
## and the RMB release that ends the look — so it comes down then.
func _sync_modal() -> void:
	if BuildDock == null:
		return
	var up: bool = _ops != null and is_instance_valid(_ops) and _ops.active != null
	BuildDock.set_modal(up and GameMode.wants_pointer())


## A mouse button while a tool is up: the wheel is its ] / [ (every tool's size / offset /
## steps, user decision 2026-10-02), LMB confirms, RMB cancels.
func _tool_mouse(mb: InputEventMouseButton) -> void:
	var ops := _ensure_ops()
	if ops.active == null or not mb.pressed:
		return
	var more := Keymap.tapped(mb, "builder.tool_more")
	if GameMode.wants_pointer() and (more or Keymap.tapped(mb, "builder.tool_less")):
		var k := InputEventKey.new()
		k.keycode = KEY_BRACKETRIGHT if more else KEY_BRACKETLEFT
		k.physical_keycode = k.keycode
		k.pressed = true
		ops.handle_key(k)
	elif mb.button_index == MOUSE_BUTTON_LEFT:
		# A TOOL'S HANDLE under the press is grabbed instead of confirming (§17.9).
		var hvp := get_viewport()
		var hmc: ModalControls = ops.active.modal()
		if hmc.grab_handle(mb.position, hvp.get_camera_3d() if hvp != null else null, _crosshair_ray(mb.position)):
			return
		ops.confirm()
	elif mb.button_index == MOUSE_BUTTON_RIGHT:
		ops.cancel()
		print("[MapDCLevel] operator cancelled — nothing was written")
	else:
		return
	_update_op_hud()
	var vp := get_viewport()
	if vp != null:
		vp.set_input_as_handled()


## Undo or redo my most recent edit — of ANY kind, from ANY session.
##
## The order rule is in LevelPersistence: undo takes my highest-seq live record,
## redo my lowest-seq undone one, which are exact inverses over a run of undos
## without storing an undo stack anywhere. Both then re-fold, because the level is
## the journal and nothing else needs telling.
func _undo_redo(redo: bool) -> void:
	var dot: String = _persist.redo_last() if redo else _persist.undo_last()
	if dot == "":
		print("[MapDCLevel] nothing to %s (undo %d / redo %d available)"
			% ["redo" if redo else "undo", _persist.undo_depth(), _persist.redo_depth()])
		return
	_persist.refold()
	print("[MapDCLevel] %s %s — %d undoable, %d redoable  %s"
		% ["redo" if redo else "undo", dot, _persist.undo_depth(), _persist.redo_depth(),
		   str(level.get_stats())])


## The immediate `_clip` that used to live here is GONE, replaced by KnifeOperator.
## Keeping both would have been two implementations of one operation — the thing that
## goes wrong every time it is allowed to happen. What the knife adds is the moment in
## which to aim: the old version had to infer the cut plane from wherever the camera
## happened to point, because pressing the key was the whole interaction.


## Tombstone the aimed brush. The other half of making a doorway.
##
## A TOMBSTONE, never an erase: GameState has no durable delete path, so an erased
## record returns on the next load. remove_brush has existed since the level pivot and
## has never had a binding — you could create geometry and never remove any.
func _delete_aimed() -> void:
	var hit := _crosshair_hit()
	if hit.is_empty():
		print("[MapDCLevel] delete: nothing under the crosshair")
		return
	var brush := _brush_from_hit(hit)
	if brush < 0:
		print("[MapDCLevel] delete: that is not level geometry")
		return
	var bkey: String = level.get_brush_key(brush)
	if not level.remove_brush(brush):
		return
	level.rebuild()
	_refresh_brush_view()
	_clear_face_selection()                 # the selection may have been on that brush
	_persist.record("brush.remove", {"brush_key": bkey})
	print("[MapDCLevel] deleted brush %d (%s) — Z to bring it back  %s"
		% [brush, bkey, str(level.get_stats())])


## CRASH PROBE. v868 carried a probe on the knife and NOT ONE of its lines appears in
## a crash log — so `C` never reaches the knife, and the last thing standing in every
## log is a pick, cut off mid-print. Everything the pick touches has been cleared
## offline on the real level (cut_preview and split_brush over all 1183 brushes, the
## operator over 399, BrushView over 1300 aims plus out-of-range ids, under both
## engines and a live Vulkan device), so the fault is in something only the running
## game reaches. This makes the pick say where it got to; stdout is line-buffered
## under a pty, so the last line printed is the step that died.
func _probe(what: String) -> void:
	print("[pick.probe] %s" % what)


## Raycast from the crosshair into the level. Shared by carving and face editing
## because the exclusion below is not optional and must not diverge between them.
##
## EXCLUDE THE PLAYERS. The camera sits inside its own player capsule, so an
## unexcluded ray hits that first and returns a point floating in mid-air. A
## mid-air point is inside NO brush's AABB — a room's walls are thin slabs at its
## edges, not a volume filling it — so apply_carve recorded a carve whose
## `touched` list was empty and the level was left completely unchanged. That is
## exactly what the first build did: 14 carves, pockets 0, triangles never moved
## off 13175.
## THE AIM ITSELF, without asking what it hits. An operator that FOLLOWS your aim
## needs the ray even where there is nothing to hit — you drag a face out into empty
## space, and a hit-only view of the aim would go blank exactly then.
## The ray every editor operation acts along — pick, delete, and every operator.
##
## IT FOLLOWS THE CURSOR WHEN THERE IS ONE. This used to be the camera's forward
## axis unconditionally, i.e. the screen-centre crosshair, which is right when the
## mouse is captured and useless the moment it is not: build mode grew a visible
## cursor, and pointing at a brush still selected whatever happened to be in the
## middle of the screen. "The mouse cannot select brushes or elements" is that,
## and it is one ray, not a selection-model problem.
##
## Named _crosshair_ray still because the crosshair is exactly what it is whenever
## the mouse is captured — during RMB-hold look, and in play.
## `at` is a viewport position to aim from, for a caller that HAS one. A click
## carries where it happened, and that is not the same thing as where the pointer
## is by the time the handler runs. Everything that polls an aim during a drag
## wants the live pointer and passes nothing, which is the default.
func _crosshair_ray(at: Variant = null) -> Dictionary:
	var vp := get_viewport()
	if vp == null:
		return {}
	var cam := vp.get_camera_3d()
	if cam == null:
		return {}
	if GameMode.wants_pointer():
		# + the WRAP OFFSET: while a pointer-following tool is up, the mouse is warped
		# across the window at its edge and this carries the drag on (#109).
		var m: Vector2 = (at as Vector2) if at != null else _pointer_pos(vp.get_mouse_position()) + _wrap_offset
		return {"from": cam.project_ray_origin(m), "dir": cam.project_ray_normal(m)}
	# Mouse captured: the pointer says nothing, the camera IS the aim, and a
	# position handed in by a caller means nothing here.
	return {"from": cam.global_position, "dir": -cam.global_transform.basis.z}


## ---- POINTER WRAP (#109, 2026-10-12: "when pressing g and trying to move something a
## very large distance the mouse has to wrap to continue moving beyond the window bounds")
##
## Blender's continuous grab. While a tool whose value FOLLOWS THE POINTER is up
## (LevelOperator.follows_aim: move, element move, extrude) and the pointer is free
## (build mode, not RMB-look), the mouse reaching within WRAP_MARGIN of an edge is warped
## to the opposite side, and the jump is added to _wrap_offset — the VIRTUAL pointer
## (mouse + offset) carries on as if the window were endless. _crosshair_ray reads it.
## The offset belongs to one tool: it resets the moment the active tool changes.
const WRAP_MARGIN := 4.0
var _wrap_offset := Vector2.ZERO
var _wrap_op: Object = null
## Where the warp goes — swapped by gate_pointer_wrap (headless has no OS cursor to move).
var pointer_warp: Callable = Callable(self, "_warp_pointer")


func _warp_pointer(p: Vector2) -> void:
	var vp := get_viewport()
	if vp != null:
		vp.warp_mouse(p)


## Where a pointer at `m` in a viewport of `size` lands after the wrap (itself when no
## wrap is due). The far side is placed one pixel INSIDE the margin, so it does not
## wrap straight back.
static func wrap_target(m: Vector2, size: Vector2, margin: float) -> Vector2:
	var out := m
	for i in 2:
		var span: float = size[i] - 2.0 * (margin + 1.0)
		if span <= 0.0:
			continue
		if m[i] >= size[i] - margin:
			out[i] = m[i] - span
		elif m[i] <= margin:
			out[i] = m[i] + span
	return out


## The mouse position the wrap and the ray should read. After a warp the viewport may
## still report the OLD position until the next motion event; read raw, that would count
## the jump twice for a frame (the brush flicking a window-width away). So the target
## stands in until the viewport reports a position near it.
var _wrap_pending: Variant = null


func _pointer_pos(raw: Vector2) -> Vector2:
	if _wrap_pending == null:
		return raw
	if raw.distance_to(_wrap_pending as Vector2) <= WRAP_MARGIN * 4.0:
		_wrap_pending = null
		return raw
	return _wrap_pending as Vector2


func _update_pointer_wrap_at(raw: Vector2) -> void:
	var op: Object = _ops.active if _ops != null and is_instance_valid(_ops) else null
	if op != _wrap_op:
		_wrap_op = op
		_wrap_offset = Vector2.ZERO
		_wrap_pending = null
	var m := _pointer_pos(raw)
	# A tool whose value DRAGS with the mouse (§17.7 Q2) wraps too: Blender's continuous grab.
	var wraps: bool = op != null and ((op.has_method("follows_aim") and bool(op.follows_aim()))
		or (op.has_method("drags_value") and bool(op.drags_value())))
	if not wraps or not GameMode.wants_pointer():
		return
	var vp := get_viewport()
	if vp == null:
		return
	# Wrap at the 3D VIEW's edges, not the window's (#124): the window's edges are the
	# editor's areas now, and a pointer wrapped there sat over a panel.
	var rect: Rect2 = BuildDock.view_rect() if BuildDock != null else vp.get_visible_rect()
	var w := wrap_target(m - rect.position, rect.size, WRAP_MARGIN) + rect.position
	if w == m:
		return
	_wrap_offset += m - w
	_wrap_pending = w
	pointer_warp.call(w)


## Is the clearance view on? Held here rather than read back off BrushView because
## it governs PICKING as well as drawing, and _crosshair_hit runs before the view
## is necessarily built.
var _show_clearance: bool = false
## LIVE CUTTERS are drawn and pickable whenever any exist (docs/cutting-and-operations.md
## §9a): they have no collider, so only dc_pick sees them. The toggle is step 2.
var _show_cutters: bool = true


## `reach` < 0 = _aim_reach() (the camera's far plane in build mode); carving passes
## PLAY_REACH so its limit is the same in every mode.
func _crosshair_hit(at: Variant = null, reach: float = -1.0) -> Dictionary:
	var ray := _crosshair_ray(at)
	if ray.is_empty():
		return {}
	# Act where the player is LOOKING, not where they stand — an edit at the
	# camera would open a hole under your feet and read as a bug.
	var from: Vector3 = ray["from"]
	var to: Vector3 = from + (ray["dir"] as Vector3) * (reach if reach > 0.0 else _aim_reach())
	var q := PhysicsRayQueryParameters3D.create(from, to)
	q.collide_with_areas = false
	var excl: Array[RID] = []
	var players := get_tree().root.get_node_or_null("Game/Players")
	if players != null:
		for p in players.get_children():
			if p is CollisionObject3D:
				excl.append((p as CollisionObject3D).get_rid())
	q.exclude = excl
	var hit: Dictionary = get_world_3d().direct_space_state.intersect_ray(q)
	# Cutters only on a DCLevel build that has them, and only when one exists — the
	# dc_pick ray below is not free, and most levels have none.
	var cutters_on: bool = _show_cutters and level != null and level.has_method("get_cutter_brush_ids") \
		and not (level.get_cutter_brush_ids() as PackedInt32Array).is_empty()
	if not _show_clearance and not cutters_on:
		return hit
	# CLEARANCE HAS NO COLLIDER — that is what it is — so the physics ray above
	# cannot see one however close it is, and the clearance view would show
	# volumes you could look straight at and never select. dc_pick answers from
	# the plane sets with no physics involved, and is the only path that can.
	#
	# NEARER WINS. A clearance volume encloses the shape it serves, so it is
	# almost always the closer of the two; taking it unconditionally would make
	# the walls unselectable for as long as the view is on, and taking the
	# physics hit unconditionally would make this do nothing.
	var to_end: Vector3 = to
	var pick: Dictionary = {}
	if level != null and level.has_method("raycast"):
		pick = level.raycast(from, to_end, _show_clearance, true) if cutters_on \
			else level.raycast(from, to_end, true)
	if pick.is_empty():
		return hit
	var ids: PackedInt32Array = level.get_clearance_brush_ids() if _show_clearance else PackedInt32Array()
	if cutters_on:
		ids.append_array(level.get_cutter_brush_ids())
	if not ids.has(int(pick.get("brush", -1))):
		return hit                    # dc_pick found ordinary geometry; physics owns that
	if not hit.is_empty() and \
			from.distance_to(hit.get("position", to_end) as Vector3) <= float(pick.get("distance", 0.0)):
		return hit
	# Shaped like a physics result so every caller reads it the same way, plus
	# `brush` — _brush_from_hit resolves a shape index, and a clearance volume has
	# no shape to index.
	return {
		"position": pick.get("position", Vector3.ZERO),
		"normal": pick.get("normal", Vector3.UP),
		"brush": int(pick.get("brush", -1)),
		"collider": null, "shape": -1,
	}


# ---- (B) face-level shape editing ------------------------------------------
#
# The architecture calls face editing "the tractable subset" and it is right
# about why: a brush vertex is where 3+ planes meet, so it cannot move on its
# own, and solving for that is the TrenchBroom problem. A FACE is one plane, and
# moving it along its own normal is exactly one number.

## THE GRID IS IN QUAKE UNITS, NOT METRES, and that is the whole point.
##
## The .MAP is authored on an integer Quake lattice and imported at WORLD_SCALE,
## so the only steps that keep a face on the grid the map was BUILT on are
## integer multiples of a unit. The first version of this nudged by 0.125 m,
## which is 4.4643 Quake units — every keypress walked the face off that lattice,
## and ten of them left it 44.6 units off, permanently and invisibly. Faces still
## looked fine; they were simply no longer where a mapper could reason about them.
##
## 8 units (0.224 m) is the default because it is already this codebase's
## quantum: dc_level.h picks the same value for pocket_voxel_size so a carved
## pocket lands on the same lattice as everything else.
##
## Powers of two, like TrenchBroom's grid (exp2 sizes of the map unit) — and its range,
## 0.25 .. 256 (editor redesign E2; TB is 0.125 .. 256).
const GRID_UNITS: Array[float] = [0.25, 0.5, 1.0, 2.0, 4.0, 8.0, 16.0, 32.0, 64.0, 128.0, 256.0]
## REMEMBERED per player (BuildDock prefs, user://editor_layout.json — never replicated):
## the setters save, _init restores, so a new map opens on the last grid and snap state.
var _grid_idx: int = 5:
	set(v):
		_grid_idx = v
		_save_editor_pref("grid", v)

## SNAPPING (editor redesign E2, docs/editor-redesign.md §4). Per player, not durable.
## OFF = steps of ONE unit, TrenchBroom's rule: brushes stay on whole units, which the
## csg4 clip and the journal's plane equality are both happiest with. While a TOOL is
## up, Ctrl inverts this and Shift divides the step by PRECISION (keymap.md rule 1c —
## outside a tool those keys fly, so they never touch snapping there).
var snap_enabled: bool = true:
	set(v):
		snap_enabled = v
		_save_editor_pref("snap", v)
const PRECISION := 8.0
## CSG: tombstone the operands afterwards, or keep them (Alt+H toggles). Per player,
## remembered like the grid. See _csg for why discarding is the default.
var csg_discard: bool = true:
	set(v):
		csg_discard = v
		_save_editor_pref("csg_discard", v)
var _prefs_loaded := false


func _init() -> void:
	var dock := _dock_node()
	if dock != null:
		_grid_idx = clampi(int(dock.pref("grid", 5)), 0, GRID_UNITS.size() - 1)
		snap_enabled = bool(dock.pref("snap", true))
		csg_discard = bool(dock.pref("csg_discard", true))
		var pm := String(dock.pref("pivot_mode", "bbox"))
		pivot_mode = pm if PIVOT_MODES.has(pm) else "bbox"
	_prefs_loaded = true


## Before the map is in the tree get_node_or_null cannot reach /root, so go through the
## main loop's root — the same node, reachable from _init.
func _dock_node() -> Node:
	var ml := Engine.get_main_loop() as SceneTree
	return ml.root.get_node_or_null("BuildDock") if ml != null else null


func _save_editor_pref(key: String, v: Variant) -> void:
	if not _prefs_loaded:
		return                      # restoring, not choosing
	var dock := _dock_node()
	if dock != null:
		dock.set_pref(key, v)


func _tool_up() -> bool:
	return Keymap.is_context_active("tool")


## The toggle as it applies RIGHT NOW: Ctrl held inside a tool inverts it.
func snap_active() -> bool:
	var inv: bool = _tool_up() and Input.is_key_pressed(KEY_CTRL)
	return snap_enabled != inv


## SNAP TARGETS (E2b): placements (box corner, knife point) snap to the aimed face's
## VERTICES / EDGES when one is within one grid step — Blender's Vertex / Edge targets,
## for "put this box's corner on that brush's corner". On top of the lattice, never
## instead of it; off whenever snapping is (so Ctrl inside a tool frees them too).
## Remembered per player.
func snap_target_enabled(kind: String) -> bool:
	var dock := _dock_node()
	return dock != null and bool(dock.pref("snap_" + kind, false))


func set_snap_target(kind: String, on: bool) -> void:
	var dock := _dock_node()
	if dock != null:
		dock.set_pref("snap_" + kind, on)


## The vertex / edge point a placement at `at` (on brush `brush`) should snap to, or
## null to fall through to the lattice. Vertex beats edge: a corner is the more
## specific intent.
func snap_target_point(at: Vector3, brush: int) -> Variant:
	if brush < 0 or level == null or not snap_active():
		return null
	var want_v := snap_target_enabled("vertex")
	var want_e := snap_target_enabled("edge")
	if not (want_v or want_e):
		return null
	var face: int = level.pick_face(brush, at)
	if face < 0:
		return null
	var w: PackedVector3Array = level.get_face_winding(brush, face)
	if w.size() < 3:
		return null
	var r: float = maxf(base_grid_units() * WORLD_SCALE, 0.05)
	if want_v:
		var best_v: Variant = null
		var bd := r
		for p: Vector3 in w:
			var dd := p.distance_to(at)
			if dd <= bd:
				bd = dd
				best_v = p
		if best_v != null:
			return best_v
	if want_e:
		var best_e: Variant = null
		var be := r
		for i in w.size():
			var q: Vector3 = Geometry3D.get_closest_point_to_segment(at, w[i], w[(i + 1) % w.size()])
			var de := q.distance_to(at)
			if de <= be:
				be = de
				best_e = q
		return best_e
	return null


## The grid size chosen in the dropdown / with - =, ignoring snap and modifiers.
func base_grid_units() -> float:
	return GRID_UNITS[_grid_idx]


## How a length reads in the player's chosen unit (Settings "editor_units": Quake units
## or metres) — every length the editor shows goes through here.
func fmt_units(u: float) -> String:
	if int(Settings.get_value("editor_units")) == 1:
		var m := "%.3f" % (u * WORLD_SCALE)
		return "%s m" % m
	# Whole units read "8u", not "8.0u"; fractions keep up to 3 places ("0.125u").
	if is_equal_approx(u, roundf(u)):
		return "%du" % int(roundf(u))
	return "%su" % String.num(u, 3)

## SELECT MODE — what `B` picks, and therefore what `G` moves.
##
## Picking used to resolve vertex/edge/face by PROXIMITY: aim near a corner and you
## got the corner, aim in the middle and you got the face. One tool for three things,
## which was the right first move — but it means you cannot ASK for a face while
## standing near its corner, and there is no way to tell the editor what you meant.
## Blender solves this with modes on 1/2/3, and the number row is free here for the
## same reason it is there: build mode is exclusive, so there are no weapons to
## collide with. `4` adds the whole brush, which is Blender's Object Mode.
const SEL_MODES: Array[String] = ["vertex", "edge", "face", "brush"]
## Face, not vertex: a face is the tractable case this representation is built around
## (one plane, one number) and it is what most edits want.
var _sel_mode: String = "face"

## THE SELECTION IS A SET, and one member of it is ACTIVE.
##
## `_sel_brush` was one int, which is issue #23 item 4 and the thing groups, layers
## and every CSG operation were waiting on: "subtract THIS from THAT" cannot even be
## expressed with one selected brush. It now means THE ACTIVE ONE — the last you
## clicked — so every existing caller still reads the brush the user is working on,
## and `_sel_brushes` carries the rest.
##
## Blender's model, deliberately, and NOT TrenchBroom's: theirs has no active concept
## at all — csgSubtract takes the selected brushes as cutters and auto-selects
## everything TOUCHING them as the target (Map_Geometry.cpp:685-700). That is fewer
## clicks and alarming on a 1183-brush level, where a cutter grazing a floor and three
## walls would carve all four without being asked.
var _sel_brushes: Array[int] = []
var _sel_brush: int = -1
# VERTEX, EDGE AND FACE ARE ONE OPERATION, NOT THREE.
#
# An element of dimension k is the meet of (3-k) planes: a face is one plane, an
# edge two, a vertex three. So dragging any of them is the same act — move the
# incident planes so they pass through the target point, keeping their normals:
#
#     d_i = n_i . P        for each incident plane
#
# Exactly determined, never a solve. And the face case collapses to the nudge
# this file already had: d = n.(P + n*step) = d + step.
#
# This is NOT TrenchBroom's vertex drag. Theirs lets the shape GAIN faces, which
# needs a polyhedron rebuild, plane re-derivation and a heuristic matcher to work
# out which face is which afterwards — the topological naming problem. Ours moves
# the incident faces bodily: drag a cube corner and you get a bigger box, not a
# bevelled one. That is inherent, not a shortcut. A brush's faces are planar by
# definition, so moving one vertex while pinning its neighbours would need
# non-planar faces, which is not a brush at all. Verified exact (err 0.00000) for
# vertex and edge on a unit cube, with the opposite corner untouched.
#
# Selection is held as DOTS, never indices: remove_face renumbers, and a dot needs
# no agreed order to be unique.
var _sel_keys: PackedStringArray = PackedStringArray()
## FACES ACROSS BRUSHES (2026-10-05): face mode, Shift-click adds a face of ANY brush.
## {b: brush key, p: plane key} — durable names, like _sel_keys, so a fold between
## clicks cannot leave it pointing at the wrong face. The active face is still
## _sel_brush/_sel_keys (nudge, extrude act on it); this set is what Alt+J / Alt+E
## build from. Empty outside face mode.
var _sel_faces: Array[Dictionary] = []
## THE EDGE SET (#120 L3 §14.4): edge mode, Shift-click adds an edge of ANY brush, as
## _sel_faces does for faces. {b: brush key, p: [plane key, plane key], sorted} — the edge
## IS where those two planes meet on that brush, so a fold between clicks cannot leave it
## pointing elsewhere, and no picked point is kept. The ACTIVE edge is still
## _sel_edge_a/_b. Cables from edges (§14.5) read it. Empty outside edge mode.
var _sel_edges: Array[Dictionary] = []


## The edge a..b of `brush` as a durable key: the two planes through both ends. {} unless
## exactly two.
func _edge_key(brush: int, a: Vector3, b: Vector3) -> Dictionary:
	var d: Dictionary = level.get_brush(brush)
	var pl: PackedFloat32Array = d.get("planes", PackedFloat32Array())
	var keys: PackedStringArray = d.get("plane_keys", PackedStringArray())
	var found: Array = []
	for i in mini(pl.size() / 4, keys.size()):
		var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
		var l := n.length()
		if l < 1.0e-9:
			continue
		var dd := pl[i * 4 + 3] / l
		n /= l
		if absf(n.dot(a) - dd) < FaceKit.ON_PLANE and absf(n.dot(b) - dd) < FaceKit.ON_PLANE:
			found.append(String(keys[i]))
	if found.size() != 2:
		return {}
	found.sort()
	return {"b": String(level.get_brush_key(brush)), "p": found}


## One edge key resolved on the level now: {b, p, a, e (the two ends, in (x, y, z) order),
## normals: [n1, n2] (the planes', in p's order)}, or {} when the brush is gone or its two
## planes no longer meet in a segment of it. `live` = the live brush ids as keys: a removed
## brush is a TOMBSTONE, which brush_by_key and get_brush still return.
func _resolve_edge(ek: Dictionary, live: Dictionary) -> Dictionary:
	var b: int = level.brush_by_key(String(ek.get("b", "")))
	if b <= 0 or not live.has(b):
		return {}
	var d: Dictionary = level.get_brush(b)
	var pl: PackedFloat32Array = d.get("planes", PackedFloat32Array())
	var keys: PackedStringArray = d.get("plane_keys", PackedStringArray())
	var p: Array = ek.get("p", [])
	if p.size() != 2:
		return {}
	var ns: Array = []
	var ds: Array = []
	for k: String in p:
		var i := keys.find(k)
		if i < 0 or i * 4 + 3 >= pl.size():
			return {}
		var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
		var l := n.length()
		if l < 1.0e-9:
			return {}
		ns.append(n / l)
		ds.append(pl[i * 4 + 3] / l)
	var on: Array = []
	for v: Vector3 in FaceKit.corners(pl):
		if absf((ns[0] as Vector3).dot(v) - float(ds[0])) < FaceKit.ON_PLANE \
				and absf((ns[1] as Vector3).dot(v) - float(ds[1])) < FaceKit.ON_PLANE:
			on.append(v)
	if on.size() != 2:
		return {}
	if FaceKit._before(on[1], on[0]):
		on.reverse()
	return {"b": ek["b"], "p": p, "a": on[0], "e": on[1], "normals": ns}


## The edge set, resolved (see _resolve_edge); edges that no longer resolve are left out.
func selected_edges() -> Array:
	var out: Array = []
	if _sel_edges.is_empty():
		return out
	var live := {}
	for id: int in (level.get_live_brush_ids() as PackedInt32Array):
		live[id] = true
	for ek: Dictionary in _sel_edges:
		var r := _resolve_edge(ek, live)
		if not r.is_empty():
			out.append(r)
	return out


## The edge set as line pairs, for the highlight.
func edge_set_lines() -> PackedVector3Array:
	var out := PackedVector3Array()
	for r: Dictionary in selected_edges():
		out.append(r["a"])
		out.append(r["e"])
	return out
## The `face_sel` keymap context follows "is anything selected" (_process).
var _face_sel_on := false
## Home / End pressed again on the SAME selection step to the next fit / edge.
var _last_uv_key := ""
var _last_uv_sig := ""
var _sel_point := Vector3.ZERO
var _sel_kind: String = ""
## The selected EDGE's endpoints, captured at pick time. _pick_element already
## computes them while testing each bounding segment; recovering them afterwards
## would mean intersecting the two incident planes and re-clipping against the brush
## to find where the edge actually stops.
var _sel_edge_a := Vector3.ZERO
var _sel_edge_b := Vector3.ZERO
var _hl: MeshInstance3D = null


## Snap a world-space plane offset to the MAP's lattice, not the world's.
##
## MEASURED, because I had assumed this was badly broken and it is not: the importer
## snaps map_center to Quake's 8-unit grid (for DC voxel alignment), so with
## world_offset zero the map's 8-unit lattice lands exactly on multiples of 8 *
## WORLD_SCALE in world space. Grids of 1, 2, 4 and 8 units were therefore ALREADY
## correct with naive world-origin snapping.
##
## What is genuinely wrong is the coarse end. START.MAP's map_center is
## (736, -88, -1704), which is a multiple of 8 but NOT of 16, 32 or 64 — so at those
## grid sizes a world-origin snap lands 8 or more units off the lattice the map was
## authored on, on whichever axes do not divide. This converts the offset back into
## Quake units, snaps there, and converts back, which is right for every grid size.
static func snap_to_map_grid(d: float, n: Vector3, units: float,
							 map_center: Vector3, aligned_offset: Vector3) -> float:
	var origin: float = n.dot(map_center)
	var quake: float = (d - n.dot(aligned_offset)) / WORLD_SCALE + origin
	var snapped: float = roundf(quake / float(units)) * float(units)
	return (snapped - origin) * WORLD_SCALE + n.dot(aligned_offset)


## One grid step in metres. Integer Quake units scaled into world space, so a
## nudged face stays on the lattice the .MAP was authored on.
func _grid_step() -> float:
	return grid_units() * WORLD_SCALE      # the EFFECTIVE step (E2): snap + modifiers


## The collider carries which brush it came from; the shape index is the join.
func _brush_from_hit(hit: Dictionary) -> int:
	# An explicit `brush` beats the shape index: a clearance volume has no
	# collision shape to index, so _crosshair_hit names the brush directly when
	# the clearance view answers the ray.
	if hit.has("brush"):
		return int(hit["brush"])
	if hit.is_empty() or not hit.has("shape") or not level.has_method("get_brush_id_from_shape"):
		return -1
	# ONLY THE LEVEL'S OWN BODY. A shape index means nothing on another body: a scene
	# box's shape 0 named the level's brush 0 — an unrelated brush that a click
	# selected and Delete removed (gate_pick_level_only). DCLevel attaches its body to
	# itself (dc_level.cpp body_attach_object_instance_id), so its hits report it.
	if hit.get("collider") != level:
		return -1
	# EACH COLLIDER HAS ITS OWN BODY now (dc-terrain, docs/incremental-fold.md step 2), so
	# the hit's body names the brush and its shape index is always 0 there.
	if hit.has("rid") and level.has_method("get_brush_id_from_body"):
		return level.get_brush_id_from_body(hit["rid"])
	return level.get_brush_id_from_shape(int(hit["shape"]))


## THE OUTLINER'S TWO ENTRY POINTS, and they are the map's API rather than the
## panel reaching into private state — a second writer of `_sel_brushes` or
## `_show_clearance` is how the two would drift, which is the mistake the
## mode/fly pair already made once.

## Show or hide the clearance wireframe, and make those volumes pickable with it.
## Called by Shift+V and by the outliner's Clearance row, so they cannot disagree.
## The clearance OVERLAY's own state (keymap step 6.5) — `_show_clearance` is the
## effective one (and the master switch).
func set_clearance_shown(shown: bool) -> void:
	set_overlay("clearance", shown)


# ---- OVERLAYS (keymap step 6.5, docs/keymap-blender-279.md §6a.5) ------------------
#
# Blender 2.8's model: a MASTER switch (Shift+Alt+Z) over one switch per overlay. Master
# off hides them all and leaves each one's own state alone. ONE state (BuildDock prefs —
# per player, CI-isolated), ONE apply pushing the EFFECTIVE state to every consumer.
# The aimed outline, its handles and the selection outlines are NOT overlays: they are
# the tool's answer to "what will a click pick".

## name -> [action, label, default]. The grid keeps its pre-existing pref key.
const OVERLAYS: Dictionary = {
	"outlines":  ["builder.brush_view", "Brush outlines", true],
	"clearance": ["builder.clearance_view", "Clearance volumes", false],
	"links":     ["builder.entity_links", "Entity links", true],
	"grid":      ["builder.grid_view", "Grid", true],
	"cutters":   ["builder.cutters_view", "Live cutters", true],
	"curves":    ["builder.curves_view", "Curves", true],
}
var _overlay_mem: Dictionary = {}       # only when there is no BuildDock (bare harnesses)


func _overlay_pref_key(name: String) -> String:
	return "grid_view" if name == "grid" else "overlay_" + name


func _pref_get(key: String, default: bool) -> bool:
	var dock := get_node_or_null("/root/BuildDock")
	if dock != null:
		return bool(dock.pref(key, default))
	return bool(_overlay_mem.get(key, default))


func _pref_set(key: String, value: bool) -> void:
	var dock := get_node_or_null("/root/BuildDock")
	if dock != null:
		dock.set_pref(key, value)
	else:
		_overlay_mem[key] = value


## The overlay's OWN switch (what its row's check mark shows).
func overlay_on(name: String) -> bool:
	return _pref_get(_overlay_pref_key(name), bool((OVERLAYS[name] as Array)[2]))


## The master switch (Shift+Alt+Z).
func overlays_all_on() -> bool:
	return _pref_get("overlays_all", true)


## What is actually drawn: the master AND the overlay's own switch.
func overlay_shown(name: String) -> bool:
	return overlays_all_on() and overlay_on(name)


func set_overlay(name: String, on: bool) -> void:
	_pref_set(_overlay_pref_key(name), on)
	_apply_overlays()


func set_overlays_all(on: bool) -> void:
	_pref_set("overlays_all", on)
	_apply_overlays()


## A key / menu row: flip one overlay and SAY what happened, with a count — "on and
## nothing to draw" must read differently from a key that does nothing.
func toggle_overlay(name: String) -> void:
	set_overlay(name, not overlay_on(name))
	var n := -1
	match name:
		"outlines":
			n = _ensure_brush_view().segment_count() if level != null else 0
		"clearance":
			n = (level.get_clearance_brush_ids() as PackedInt32Array).size() \
				if level != null and level.has_method("get_clearance_brush_ids") else 0
		"links":
			n = _ensure_entity_links().link_count()
		"cutters":
			n = (level.get_cutter_brush_ids() as PackedInt32Array).size() \
				if level != null and level.has_method("get_cutter_brush_ids") else 0
		"curves":
			n = curve_states().size()
	print("[MapDCLevel] %s %s%s%s" % [String((OVERLAYS[name] as Array)[1]).to_lower(),
		"on" if overlay_on(name) else "off", " (%d)" % n if n >= 0 else "",
		"" if overlays_all_on() else " — all overlays are off (%s)" % Keymap.describe("builder.overlays_all")])


## Push the effective states. Touches only nodes that exist; _ensure_brush_view and
## _ensure_entity_links take theirs when they are made.
func _apply_overlays() -> void:
	_show_clearance = overlay_shown("clearance")
	_show_cutters = overlay_shown("cutters")
	if _brush_view != null and is_instance_valid(_brush_view):
		_push_overlays(_brush_view)
	if _entity_links != null and is_instance_valid(_entity_links):
		_entity_links.visible = _links_visible()
	if _curve_view != null and is_instance_valid(_curve_view):
		_curve_view.visible = _curves_visible()


## Curves (wire + points) are an editing aid: build mode, with their overlay (user
## 2026-10-18: "the cable points ... dont go away when you press f4").
func _curves_visible() -> bool:
	return GameMode.is_building() and overlay_shown("curves")


func _push_overlays(bv: BrushView) -> void:
	bv.show_outlines = overlay_shown("outlines")
	bv.show_clearance = _show_clearance
	bv.show_cutters = _show_cutters


## Entity links show in build mode, with their overlay, unless the Outliner's eye hid
## the node (outliner_model.gd sets that meta and calls _apply_overlays).
func _links_visible() -> bool:
	return GameMode.is_building() and overlay_shown("links") \
		and not bool(_entity_links.get_meta("outliner_hidden", false))


## Select a set of brushes from outside the viewport. The outliner selects by
## ROW — a collection, a derived group — and everything downstream is unchanged:
## `G` moves what is selected using the operator that already exists, which is
## #28 §1's whole argument for why the panel is not a new way to move things.
# ---- PARAMETRIC SHAPES in the editor (docs/parametric-shapes-design.md §4, §6) ------
#
# A shape is ONE thing: clicking any of its pieces selects all of them, in every pick
# mode — the pieces are READ-ONLY (§7, user Q1), so a face pick must not reach one.
# Move / rotate / scale on a selected shape write shape.xform / shape.size (the fold
# regenerates the pieces), never per-piece brush records, which the next generation
# would orphan. Make Real (§6) hands the pieces over as plain brushes.
#
# _sel_shape is HELD, not derived: every edit regenerates the pieces under NEW names
# (shape_fold.gd <ver>), so the ids in _sel_brushes die on each fold — _reselect_shape
# re-takes them from the shape's group after it.

var _sel_shape := ""
const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")


## The live shape a brush is a piece of, or "".
func shape_of_brush(id: int) -> String:
	if id < 0 or level == null:
		return ""
	_ensure_persistence()
	var g := String(level.get_brush_group(id))
	return g if g != "" and (_persist.live_shapes() as Dictionary).has(g) else ""


func selected_shape() -> String:
	return _sel_shape


func select_shape(dot: String) -> void:
	select_brushes(level.get_group_brush_ids(dot))
	_sel_shape = dot
	print("[MapDCLevel] selected shape %s (%d pieces)" % [dot, _sel_brushes.size()])


func clear_selection() -> void:
	_clear_face_selection()


## After a fold: the selected shape's pieces have new ids (or it is gone).
func _reselect_shape() -> void:
	if _sel_asset != "":
		# A placed asset (#120 L4): its pieces are renamed by a move, or it is gone.
		var pd := _sel_asset
		var p: Dictionary = (_persist.placements() as Dictionary).get(pd, {})
		if p.is_empty() or bool(p["removed"]):
			_clear_face_selection()
			return
		var aids := asset_brush_ids(pd)
		if aids != PackedInt32Array(_sel_brushes):
			select_brushes(aids)
			_sel_asset = pd
		return
	if _sel_shape == "":
		return
	var dot := _sel_shape
	if not (_persist.live_shapes() as Dictionary).has(dot):
		_clear_face_selection()
		return
	var ids: PackedInt32Array = level.get_group_brush_ids(dot)
	if ids == PackedInt32Array(_sel_brushes):
		return
	select_brushes(ids)
	_sel_shape = dot


## The live shapes among `ids`, the ids that are plain brushes, and the PLACED ASSETS
## (#120 L4) whose pieces are among them: [{dot: true}, [ids], {placement dot: true}]. A
## placement's pieces are never plain: every edit goes to its record (asset.*).
func _split_shapes(ids: Array) -> Array:
	var shapes := {}
	var rest: Array = []
	var assets := {}
	for b: Variant in ids:
		var pa := placement_of_brush(int(b))
		if pa != "":
			assets[pa] = true
			continue
		var d := shape_of_brush(int(b))
		if d != "":
			shapes[d] = true
		else:
			rest.append(int(b))
	return [shapes, rest, assets]


# ---- PLACED ASSETS in the editor (#120 L4 §15.3) ----------------------------------
#
# A placement is ONE thing, like a shape: clicking any of its pieces (or its objects)
# selects it; G / R write asset.xform, X asset.remove, Shift+D / Alt+D a new asset.place;
# its parts are read-only (Make Real hands them over). _sel_asset is HELD like _sel_shape:
# a move renames its pieces, so _reselect_shape re-takes them after the fold.

var _sel_asset := ""


## The placement a brush is a piece of, or "".
func placement_of_brush(id: int) -> String:
	if id < 0 or level == null:
		return ""
	_ensure_persistence()
	var g := String(level.get_brush_group(id))
	return String(_persist.instance_of(g)) if g != "" else ""


## A placement's live brush ids.
func asset_brush_ids(pdot: String) -> PackedInt32Array:
	var out := PackedInt32Array()
	if level == null or pdot == "":
		return out
	_ensure_persistence()
	for b: int in (level.get_live_brush_ids() as PackedInt32Array):
		var g := String(level.get_brush_group(b))
		if g != "" and String(_persist.instance_of(g)) == pdot:
			out.append(b)
	return out


func selected_asset() -> String:
	return _sel_asset


func select_asset(pdot: String) -> void:
	select_brushes(asset_brush_ids(pdot))
	_sel_asset = pdot
	print("[MapDCLevel] selected placed asset %s (%d pieces)" % [pdot, _sel_brushes.size()])


## Write a placement's new frame (asset.xform). True if recorded.
func _record_asset_frame(pdot: String, frame: Transform3D) -> bool:
	return _persist.record("asset.xform", {"target": pdot, "frame": SceneObjects.xform_to(frame)}) != ""


func _asset_frame(pdot: String) -> Transform3D:
	return (_persist.placements() as Dictionary).get(pdot, {}).get("frame", Transform3D.IDENTITY)


## Shift+D / Alt+D on a placement: a new asset.place of the same asset at the same frame,
## linked into the same collections (every placement is already linked data). Returns its dot.
func duplicate_asset(pdot: String) -> String:
	var p: Dictionary = (_persist.placements() as Dictionary).get(pdot, {})
	if p.is_empty():
		return ""
	var d: String = _persist.record("asset.place", {"asset": p["asset"], "name": p["name"],
		"frame": SceneObjects.xform_to(p["frame"])})
	if d != "":
		var t: Dictionary = _persist.collection_tree()
		for c: String in CollectionFold.collections_of(t, "a:" + pdot):
			_persist.record("col.link", {"target": c, "members": ["a:" + d]})
	return d


## MAKE REAL on a placement (§15.3): its records — exactly what the fold expands it to
## (AssetFold.payloads, shared) — journalled as ordinary ones under FRESH dots, shapes still
## parametric and cuts still live, in a NEW collection named after the asset (its
## sub-collections beneath it, under the placement's first collection if it is in one); then
## asset.remove. ONE gesture: Ctrl+Z brings the placement back. Returns how many records.
func make_real_asset(pdot: String) -> int:
	_ensure_persistence()
	var p: Dictionary = (_persist.placements() as Dictionary).get(pdot, {})
	var docs: Dictionary = _persist.get("_asset_docs")
	if p.is_empty() or bool(p["removed"]) or not docs.has(String(p["asset"])):
		print("[MapDCLevel] make real: %s is not a placement whose asset is here" % pdot)
		return 0
	var doc: Dictionary = docs[String(p["asset"])]
	var names := LevelPersistence.AssetFold.local_names(doc)
	var fresh := {}
	var locals: Array = names.keys()
	locals.sort_custom(func(a: String, b: String) -> bool: return int(names[a]) < int(names[b]))
	_persist.begin_gesture("Make Real")
	for l: String in locals:
		fresh[l] = _persist.reserve_dot()
	var dot_of := func(l: String) -> String: return String(fresh.get(l, ""))
	var n := 0
	for e: Dictionary in LevelPersistence.AssetFold.payloads(p, doc, dot_of):
		var pl: Dictionary = (e["payload"] as Dictionary).duplicate(true)
		var kind := String(pl["kind"])
		pl.erase("kind")
		var at: String = fresh[String(e["local"])] if String(e["suffix"]) == "" else _persist.reserve_dot()
		if _persist.record_at(at, kind, pl):
			n += 1
	# Organisation: one collection named after the asset holds the top level; the blob's
	# sub-collections are made beneath it with their members remapped.
	var t: Dictionary = _persist.collection_tree()
	var home := CollectionFold.collections_of(t, "a:" + pdot)
	var top: String = _persist.record("col.create", {"name": String(p["name"]),
		"parent": String(home[0]) if not home.is_empty() else ""})
	var in_sub := {}
	var sub_dot := {}
	for r: Dictionary in (doc["records"] as Array):
		if String(r["kind"]) == "col.create":
			sub_dot[String(r["dot"])] = _persist.record("col.create", {"name": String(r["name"]),
				"parent": sub_dot.get(String(r["parent"]), top)})
	for r2: Dictionary in (doc["records"] as Array):
		if String(r2["kind"]) != "col.create":
			continue
		var ms: Array = []
		for m: String in (r2["members"] as Array):
			var l2 := m.substr(m.find(":") + 1)
			in_sub[l2] = true
			if fresh.has(l2):
				ms.append(m.substr(0, m.find(":") + 1) + String(fresh[l2]))
		if not ms.is_empty():
			_persist.record("col.link", {"target": sub_dot[String(r2["dot"])], "members": ms})
	var top_ms: Array = []
	for r3: Dictionary in (doc["records"] as Array):
		var l3 := String(r3["dot"])
		if in_sub.has(l3):
			continue
		match String(r3["kind"]):
			"curve.create":
				if bool(r3.get("member", false)):        # linked into the root (§16.2)
					top_ms.append("c:" + String(fresh[l3]))
			"shape.create":
				top_ms.append("g:" + String(fresh[l3]))
			"object.create":
				if String(r3.get("parent", "")) == "":
					top_ms.append("obj:" + String(fresh[l3]))
			"brush.create":
				var lg := String(r3["group"])
				if not in_sub.has(lg) and not top_ms.has("g:" + String(fresh[lg])):
					top_ms.append("g:" + String(fresh[lg]))
	if not top_ms.is_empty():
		_persist.record("col.link", {"target": top, "members": top_ms})
	_persist.record("asset.remove", {"target": pdot})
	_persist.end_gesture()
	if _sel_asset == pdot:
		_sel_asset = ""
	_persist.refold()
	_clear_face_selection()
	print("[MapDCLevel] made placed asset %s real: %d record(s), collection \"%s\"" % [pdot, n, p["name"]])
	return n


## Write a shape's new frame (and size). Refolds; returns true if recorded.
func _record_shape_frame(dot: String, frame: Transform3D, size: Variant = null) -> bool:
	# A CABLE has no frame of its own (it is its curve's bounds): the move goes to the
	# curve, ONE curve.set with every point carried by new frame · scale · old frame⁻¹
	# (#120 L3 §11.3, "G on a cable moves its curve").
	var st: Dictionary = _persist.shape_state(dot)
	if String(st.get("type", "")) == "cable":
		var cdot := String((st["params"] as Dictionary).get("curve", ""))
		var cv: Dictionary = _persist.curve_states().get(cdot, {})
		if cv.is_empty():
			return false
		var s0: Vector3 = st["size"]
		var sc := Vector3.ONE
		if size is Vector3:
			sc = (size as Vector3) / s0.max(Vector3.ONE * 1.0e-6)
		var d: Transform3D = frame * Transform3D(Basis.from_scale(sc), Vector3.ZERO) * (st["frame"] as Transform3D).affine_inverse()
		return _persist.record("curve.set", {"target": cdot,
			"points": LevelPersistence.CurveFold.to_wire(curve_points_by(cv["points"], d))}) != ""
	var ok: bool = _persist.record("shape.xform", {"target": dot, "frame": SceneObjects.xform_to(frame)}) != ""
	if size is Vector3:
		var s: Vector3 = size
		ok = _persist.record("shape.size", {"target": dot, "size": [s.x, s.y, s.z]}) != "" and ok
	return ok


## ---- A SELECTED SHAPE'S FACE HANDLES (#120 §17.7 Q3a, TrenchBroom's) ----------------
## Six dots, one at each face's centre, while ONE shape is selected and no tool is up. A
## cable has no box of its own (its frame is its curve's bounds) and a placed asset is
## rigid: neither has handles.
const HANDLE_PX := 14.0
var _handle_view: MeshInstance3D = null


## [{id: "x+" | "x-" | …, pos, axis: 0..2, sign: ±1}], or [] when no shape has handles.
func shape_handles() -> Array:
	var out: Array = []
	if _sel_shape == "" or _persist == null or not GameMode.is_building():
		return out
	var st: Dictionary = _persist.shape_state(_sel_shape)
	if st.is_empty() or String(st.get("type", "")) == "cable":
		return out
	var fr: Transform3D = st["frame"]
	var sz: Vector3 = st["size"]
	for i in 3:
		for s: int in [1, -1]:
			var c := sz * 0.5
			c[i] = sz[i] if s > 0 else 0.0
			out.append({"id": "%s%s" % ["xyz"[i], "+" if s > 0 else "-"], "pos": fr * c, "axis": i, "sign": s})
	return out


## A press at screen point `at`: the nearest face handle within HANDLE_PX starts its drag.
## True if one did.
func press_shape_handle(at: Vector2) -> bool:
	var hs := shape_handles()
	var cam := get_viewport().get_camera_3d() if get_viewport() != null and not hs.is_empty() else null
	if cam == null:
		return false
	var best: Dictionary = {}
	var best_px := HANDLE_PX
	for h: Dictionary in hs:
		if cam.is_position_behind(h["pos"]):
			continue
		var px := cam.unproject_position(h["pos"]).distance_to(at)
		if px < best_px:
			best_px = px
			best = h
	if best.is_empty():
		return false
	_handle_op.setup(_sel_shape, int(best["axis"]), int(best["sign"]))
	return _ensure_ops().activate(_handle_op)


## ONE gesture: a shape's new frame and size (a face handle's drag). True if recorded.
func set_shape_extent(dot: String, frame: Transform3D, size: Vector3) -> bool:
	_ensure_persistence()
	_persist.begin_gesture("Resize")
	var ok := _record_shape_frame(dot, frame, size)
	_persist.end_gesture()
	_shapes_transformed(1 if ok else 0)
	return ok


## The dots, redrawn when the selection or the shape changes.
func _refresh_shape_handles() -> void:
	var hs := shape_handles()
	if hs.is_empty() and _handle_view == null:
		return
	if _handle_view == null or not is_instance_valid(_handle_view):
		_handle_view = MeshInstance3D.new()
		_handle_view.name = "ShapeHandles"
		_handle_view.mesh = ImmediateMesh.new()
		var m := StandardMaterial3D.new()
		m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
		m.albedo_color = Color(0.4, 0.85, 1.0)
		m.no_depth_test = true
		m.use_point_size = true
		m.point_size = 11.0
		_handle_view.material_override = m
		_handle_view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		add_child(_handle_view)
	var im: ImmediateMesh = _handle_view.mesh
	im.clear_surfaces()
	if hs.is_empty():
		return
	im.surface_begin(Mesh.PRIMITIVE_POINTS)
	for h: Dictionary in hs:
		im.surface_add_vertex(h["pos"])
	im.surface_end()


func _shapes_transformed(n: int) -> int:
	if n > 0:
		_persist.refold()
		_reselect_shape()
		_sel_point = _selection_centre()
	return n


## Set some of a shape's parameters — one shape.set, one gesture (the Shape panel).
func set_shape_params(dot: String, params: Dictionary, label: String = "") -> bool:
	_ensure_persistence()
	var st: Dictionary = _persist.shape_state(dot)
	if st.is_empty() or params.is_empty():
		return false
	var t: Object = ParamShapes.get_type(String(st["type"]))
	_persist.begin_gesture(label if label != "" else "Edit %s" % (t.label() if t != null else "shape"))
	var ok: bool = _persist.record("shape.set", {"target": dot, "params": params}) != ""
	_persist.end_gesture()
	if ok:
		_persist.refold()
		_reselect_shape()
	return ok


## Texture a whole shape: ONE shape.set {target, tex} (one gesture, one Ctrl+Z). Returns
## how many solid planes now carry it (0 when the journal refused).
func set_shape_tex(dot: String, tex: Dictionary) -> int:
	_ensure_persistence()
	if (_persist.shape_state(dot) as Dictionary).is_empty():
		return 0
	_persist.begin_gesture("Texture shape")
	var ok: bool = _persist.record("shape.set", {"target": dot, "params": {}, "tex": tex}) != ""
	_persist.end_gesture()
	if not ok:
		return 0
	_persist.refold()
	_reselect_shape()
	_note_recent_texture(String(tex.get("tex", "")))
	var n := 0
	for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
		if not bool((level.get_brush(b) as Dictionary).get("clearance", false)):
			n += (level.get_brush(b).get("planes", PackedFloat32Array()) as PackedFloat32Array).size() / 4
	print("[texture] %s on shape %s (%d face(s))" % [tex.get("tex", ""), dot, n])
	return n


## The last make_real's pieces: old piece id -> the dot of the plain brush it became
## (same planes, same order). What carries a selected part across (_q_make_real_then).
var last_real: Dictionary = {}


## MAKE PARAMETRIC (#120 F2, docs/node-tree-and-stacks.md §3.1): each selected plain brush
## becomes a parametric shape that generates it — `box` when it IS its own box in its local
## frame (size then edits it), else `hull` (its planes in the shape's unit box, each face
## keeping its texture). The frame is the brush's own basis (brush_local_basis) at its
## bounds' low corner. ONE gesture: shape.create + brush.remove per brush, and the shape is
## linked into the collections its brush's object was in. Make Real is the inverse. A
## shape's piece is refused (it is parametric already). Returns the new shape dots.
func make_parametric() -> Array[String]:
	_ensure_persistence()
	var made: Array[String] = []
	var ids: Array = _sel_brushes.duplicate()
	if ids.is_empty():
		print("[MapDCLevel] make parametric: select brushes first")
		return made
	var tree: Dictionary = _persist.collection_tree()
	_persist.begin_gesture("Make parametric")
	for v: Variant in ids:
		var b := int(v)
		if shape_of_brush(b) != "":
			continue
		var d: Dictionary = level.get_brush(b)
		var planes: PackedFloat32Array = d.get("planes", PackedFloat32Array())
		var corners := FaceKit.corners(planes)
		if corners.size() < 4:
			continue
		var basis := brush_local_basis(b).orthonormalized()
		var bt := basis.transposed()                    # orthonormal: inverse = transpose
		var lo := Vector3.INF
		var hi := -Vector3.INF
		for c: Vector3 in corners:
			var lc := bt * c
			lo = lo.min(lc)
			hi = hi.max(lc)
		var size := (hi - lo).max(Vector3.ONE * 1.0e-4)
		var frame := Transform3D(basis, basis * lo)
		# World plane n·x <= d with x = B (lo + S u)  ->  (S Bᵀn)·u <= d - (Bᵀn)·lo.
		var unit: Array = []
		var boxy := planes.size() == 24
		for i in planes.size() / 4:
			var ln := bt * Vector3(planes[i * 4], planes[i * 4 + 1], planes[i * 4 + 2])
			var m := ln * size
			var dd := planes[i * 4 + 3] - ln.dot(lo)
			var l := m.length()
			unit.append_array([m.x / l, m.y / l, m.z / l, dd / l])
			var axial := absf(absf(ln.normalized().x) + absf(ln.normalized().y) + absf(ln.normalized().z) - 1.0) < 1.0e-4
			boxy = boxy and axial
		var payload := {"gv": 1, "frame": SceneObjects.xform_to(frame), "size": [size.x, size.y, size.z],
			"material_id": int(d.get("material_id", 0)), "level_id": int(d.get("level_id", 0)), "tex": {}}
		var ft: Array = d.get("face_tex", [])
		if boxy and ft.all(func(t: Variant) -> bool: return str(t) == str(ft[0])):
			payload["type"] = "box"
			payload["params"] = {}
			if not ft.is_empty() and ft[0] is Dictionary:
				payload["tex"] = (ft[0] as Dictionary).duplicate(true)
		else:
			payload["type"] = "hull"
			payload["params"] = {"planes": unit, "face_tex": ft.duplicate(true)}
		var sd: String = _persist.record("shape.create", payload)
		if sd == "":
			continue
		var g := String(level.get_brush_group(b))
		var alone: bool = g != "" and (level.get_group_brush_ids(g) as PackedInt32Array).size() == 1
		_persist.record("brush.remove", {"brush_key": String(level.get_brush_key(b))})
		if g != "":
			_link_copy(tree, "g:" + g, "g:" + sd)
			if alone:                                  # the shape REPLACES that object
				for c: String in CollectionFold.collections_of(tree, "g:" + g):
					_persist.record("col.unlink", {"target": c, "members": ["g:" + g]})
		made.append(sd)
	_persist.end_gesture()
	if made.is_empty():
		print("[MapDCLevel] make parametric: nothing to convert (a shape's piece is parametric already)")
		return made
	_persist.refold()
	if made.size() == 1:
		select_shape(made[0])
	print("[MapDCLevel] made %d brush(es) parametric: %s" % [made.size(), str(made)])
	return made


## ASSIGN A GENERATOR (#120 F2, #109 cube -> stairs): the shape `dot` becomes `type`, its
## current generator kept as the new one's PASSAGE (shape.set input) — a box drawn where the
## stairs go stays their air. Choosing the input's own type REMOVES the generator: the
## input is back as the shape and the passage is cleared. Params accumulate, so switching
## back to a type restores what it had. ONE gesture; Ctrl+Z returns to the previous type.
func assign_generator(dot: String, type: String) -> bool:
	_ensure_persistence()
	var st: Dictionary = _persist.shape_state(dot)
	var t: Object = ParamShapes.get_type(type)
	if st.is_empty() or t == null or String(st.get("type", "")) == type:
		return false
	var inp: Dictionary = st.get("input", {})
	var payload := {"target": dot, "type": type, "gv": int(t.GV)}
	if not inp.is_empty() and String(inp.get("type", "")) == type:
		payload["input"] = {}                    # back to the input: the generator removed
		payload["params"] = (inp.get("params", {}) as Dictionary).duplicate(true)
	elif inp.is_empty():
		payload["input"] = {"type": String(st["type"]),
			"params": (st.get("params", {}) as Dictionary).duplicate(true)}
	_persist.begin_gesture("Generator: %s" % t.label())
	var ok: bool = _persist.record("shape.set", payload) != ""
	_persist.end_gesture()
	_persist.refold()
	if _sel_shape == dot or ok:
		select_shape(dot)
	print("[MapDCLevel] %s is now %s" % [dot, type])
	return ok


## ---- CURVES (#120 F5, CurveFold + CurveMath) -----------------------------------
## A curve is room state with no brushes: points with handles, tilt, radius and the
## surface normal they were placed on. Drawn as wire (CurveView); follow-curve, cables and
## flanges sample it with CurveMath. Each edit is one gesture.

## A new curve from CurveMath points; auto handles when `auto`. Its dot, or "".
func create_curve(points: Array, closed: bool = false, name: String = "Curve", auto: bool = true) -> String:
	_ensure_persistence()
	if points.size() < 2:
		return ""
	var pts: Array = CurveMath.auto_handles(points, closed) if auto else points
	_persist.begin_gesture("Add curve")
	var dot: String = _persist.record("curve.create", {"points": LevelPersistence.CurveFold.to_wire(pts),
		"closed": closed, "name": name})
	_persist.end_gesture()
	_persist.refold()
	return dot


## ADD A CABLE (#120 L3, §11.3): a curve through `points` and a cable shape on it, ONE
## gesture. `params` = the cable's (param_cable.gd; curve is filled in). Returns the
## shape's dot ("" when refused).
func add_cable(points: Array, closed: bool = false, params: Dictionary = {}, auto: bool = true) -> String:
	_ensure_persistence()
	if points.size() < 2:
		return ""
	_persist.begin_gesture("Add cable")
	var dot := _record_cable(points, closed, params, auto)
	_persist.end_gesture()
	_persist.refold()
	if dot != "":
		select_shape(dot)
	return dot


## One point's fields ({p: Vector3, tilt: float, …}) — what a drag writes.
## Moving `p` RE-DERIVES the handles of auto / vector points (#129 deferred bug: they kept
## their old stored handles and the spans bent): every point whose derived handles change
## gets its own per-point curve.set, in the same gesture. aligned / free keep theirs
## (CurveMath.auto_handles' rule).
func set_curve_point(dot: String, index: int, fields: Dictionary) -> void:
	_ensure_persistence()
	_persist.begin_gesture("Edit curve")
	_persist.record("curve.set", {"target": dot, "index": index,
		"fields": LevelPersistence.CurveFold.point_to_wire(fields)})
	var cv: Dictionary = curve_states().get(dot, {})
	if fields.has("p") and not fields.has("h_in") and not fields.has("h_out") and not cv.is_empty():
		var pts: Array = cv["points"]
		var derived: Array = CurveMath.auto_handles(pts, bool(cv["closed"]))
		for i in pts.size():
			var q: Dictionary = pts[i]
			var d: Dictionary = derived[i]
			if not (q["h_in"] as Vector3).is_equal_approx(d["h_in"]) or not (q["h_out"] as Vector3).is_equal_approx(d["h_out"]):
				_persist.record("curve.set", {"target": dot, "index": i,
					"fields": LevelPersistence.CurveFold.point_to_wire({"h_in": d["h_in"], "h_out": d["h_out"]})})
	_persist.end_gesture()
	_persist.refold()


func remove_curve(dot: String) -> void:
	_ensure_persistence()
	_persist.begin_gesture("Delete curve")
	_persist.record("curve.remove", {"target": dot})
	_persist.end_gesture()
	_persist.refold()


func curve_states() -> Dictionary:
	_ensure_persistence()
	return _persist.curve_states()


## THE SELECTED CURVE (#120 §16.4): a curve is an object with no brushes, so it has a
## slot of its own, HELD like _sel_shape. Selecting one drops every other selection, and
## every other selection drops it (one selection at a time; see _sel_object).
## A setter, because every other selection CLEARS it in five places: each change redraws
## the curve view (deferred, once per frame) so the selected colour follows.
var _sel_curve := "":
	set(v):
		if v == "":
			_sel_curves.clear()             # dropping the active curve drops them all
		if v != _sel_curve:
			_sel_curve = v
			if not _curve_redraw_queued:
				_curve_redraw_queued = true
				_redraw_curves_deferred.call_deferred()
var _curve_redraw_queued := false
## Every selected curve (§17.10), the active one (_sel_curve) last.
var _sel_curves: Array[String] = []


func _redraw_curves_deferred() -> void:
	_curve_redraw_queued = false
	_refresh_curves()


## Curve points carried by `d`: positions by d, handles by its basis, surface normals by
## the inverse-transpose (so a scale keeps them normal). Shared by G / R on a curve and
## by a cable's move (_record_shape_frame).
static func curve_points_by(points: Array, d: Transform3D) -> Array:
	var nb := d.basis.inverse().transposed()
	var pts: Array = []
	for q: Dictionary in points:
		var q2 := q.duplicate(true)
		q2["p"] = d * (q["p"] as Vector3)
		q2["h_in"] = d.basis * (q["h_in"] as Vector3)
		q2["h_out"] = d.basis * (q["h_out"] as Vector3)
		var n: Vector3 = q.get("n", Vector3.ZERO)
		q2["n"] = (nb * n).normalized() if n.length() > 1.0e-6 else n
		pts.append(q2)
	return pts


## G / R ON A CURVE (§16.7 Q3): every point carried by `d`, ONE curve.set {points}, one
## gesture. The curve stays selected. True if recorded.
func transform_curve(dot: String, d: Transform3D, label: String = "Move curve") -> bool:
	_ensure_persistence()
	var cv: Dictionary = curve_states().get(dot, {})
	if cv.is_empty():
		return false
	_persist.begin_gesture(label)
	var ok: bool = _persist.record("curve.set", {"target": dot,
		"points": LevelPersistence.CurveFold.to_wire(curve_points_by(cv["points"], d))}) != ""
	_persist.end_gesture()
	_persist.refold()
	return ok


## The centre of a curve's points' bounds (G grabs it, R turns about it).
func curve_centre(dot: String) -> Vector3:
	var pts: Array = curve_states().get(dot, {}).get("points", [])
	if pts.is_empty():
		return Vector3.ZERO
	var bb := AABB(pts[0]["p"], Vector3.ZERO)
	for q: Dictionary in pts:
		bb = bb.expand(q["p"])
	return bb.get_center()


## A curve's wire as segment pairs, carried by `d` (the G / R preview).
func curve_wire(dot: String, d: Transform3D = Transform3D.IDENTITY) -> PackedVector3Array:
	var out := PackedVector3Array()
	var c: Dictionary = curve_states().get(dot, {})
	if c.is_empty():
		return out
	var pts: Array = c["points"]
	var n := clampi(int(CurveMath.length(pts, bool(c["closed"])) / 0.25), 8, 512)
	var fs := CurveMath.sample(pts, bool(c["closed"]), n)
	for i in range(1, fs.size()):
		out.append(d * (fs[i - 1]["p"] as Vector3))
		out.append(d * (fs[i]["p"] as Vector3))
	return out


## PICKING A CURVE BY ITS WIRE (§16.7 Q4). The wire is drawn over everything, so it is
## picked by ANGLE, not by a collider: the visible curve whose sampled wire passes nearest
## the ray, within PICK_CURVE_ANGLE (~9 px at 1080p / 70°; at least PICK_CURVE_MIN).
## {dot, dist} — dist along the ray, for _pick_element's nearer-wins — or {}. Hidden
## curves, and every curve while their overlay is off, cannot be picked.
const PICK_CURVE_ANGLE := 0.01
const PICK_CURVE_MIN := 0.05


func pick_curve(at: Variant = null) -> Dictionary:
	if _persist == null or not _curves_visible():
		return {}
	var ray := _crosshair_ray(at)
	if ray.is_empty():
		return {}
	var from: Vector3 = ray["from"]
	var to: Vector3 = from + (ray["dir"] as Vector3) * _aim_reach()
	var best := {}
	var best_err := INF
	var dots: Array = (_persist.curve_states() as Dictionary).keys()
	dots.sort()                                    # a tie goes the same way every time
	for d: String in dots:
		if _hidden_curves.has(d):
			continue
		var w := curve_wire(d)
		for i in range(0, w.size(), 2):
			var cp: PackedVector3Array = Geometry3D.get_closest_points_between_segments(from, to, w[i], w[i + 1])
			var along := from.distance_to(cp[0])
			var off := cp[0].distance_to(cp[1])
			if off > maxf(PICK_CURVE_MIN, along * PICK_CURVE_ANGLE):
				continue
			var err := off / maxf(along, 1.0e-3)
			if err < best_err:
				best_err = err
				best = {"dot": d, "dist": along}
	return best


## HIDDEN CURVES (§16.4): view-local, set by the Outliner's apply() from its rows (the eye,
## H, a hidden collection or cable above). CurveView leaves them out.
var _hidden_curves := {}


func set_hidden_curves(dots: Array) -> void:
	var h := {}
	for d: Variant in dots:
		h[String(d)] = true
	if h == _hidden_curves:
		return
	_hidden_curves = h
	_refresh_curves()


func is_curve_hidden(dot: String) -> bool:
	return _hidden_curves.has(dot)


## Shift+D / Alt+D on a curve: a copy (curve.create, same points), linked into the same
## collections, one gesture; the copy is selected. A curve is its own data, so linked is
## the same as plain. Returns the copy's dot.
func duplicate_curve(dot: String) -> String:
	_ensure_persistence()
	var cv: Dictionary = curve_states().get(dot, {})
	if cv.is_empty():
		return ""
	var tree: Dictionary = _persist.collection_tree()
	_persist.begin_gesture("Duplicate")
	var nd: String = _persist.record("curve.create", {"points": LevelPersistence.CurveFold.to_wire(cv["points"]),
		"closed": bool(cv["closed"]), "name": String(cv["name"])})
	if nd != "":
		_link_copy(tree, "c:" + dot, "c:" + nd)
	_persist.end_gesture()
	_persist.refold()
	if nd != "":
		select_curve(nd)
	return nd


## The reason the last delete refused (a used curve names its users, §16.7 Q2); the
## outliner shows it, gates read it.
var _last_refusal := ""


## X ON A CURVE: curve.remove, one gesture — REFUSED while a cable or a modifier uses it
## (§16.7 Q2), naming them. Returns 1 if deleted. (remove_curve is the unconditional
## low-level call the journal gates use.)
func delete_curve(dot: String) -> int:
	_ensure_persistence()
	_last_refusal = ""
	if not curve_states().has(dot):
		return 0
	var users: Array = _persist.curve_users(dot)
	if not users.is_empty():
		var names: Array = users.map(func(d: String) -> String:
			var t := String((_persist.shape_state(d) as Dictionary).get("type", ""))
			return "%s %s" % [t if t != "" else "modifier", d])
		_last_refusal = "the curve is used by %s — delete or re-point those first" % ", ".join(names)
		print("[MapDCLevel] delete: %s" % _last_refusal)
		return 0
	_persist.begin_gesture("Delete curve")
	_persist.record("curve.remove", {"target": dot})
	_persist.end_gesture()
	if _sel_curve == dot:
		_clear_face_selection()
	_persist.refold()
	print("[MapDCLevel] deleted curve %s — Ctrl+Z brings it back" % dot)
	return 1


func selected_curve() -> String:
	return _sel_curve


## Rename a curve: one curve.set {name} (F2 / double-click on its outliner row).
func rename_curve(dot: String, name: String) -> void:
	_ensure_persistence()
	if not curve_states().has(dot) or name.strip_edges() == "":
		return
	_persist.begin_gesture("Rename curve")
	_persist.record("curve.set", {"target": dot, "name": name.strip_edges()})
	_persist.end_gesture()
	_persist.refold()


## `add` (Shift, §17.10): toggle `dot` in the curve list and keep every other selection;
## the last one added is the active curve. Without it the selection is replaced.
func select_curve(dot: String, add: bool = false) -> void:
	if add:
		if dot == "" or not curve_states().has(dot):
			return
		var at := _sel_curves.find(dot)
		if at >= 0:
			_sel_curves.remove_at(at)
		else:
			_sel_curves.append(dot)
		var keep := _sel_curves.duplicate()
		_sel_curve = _sel_curves[-1] if not _sel_curves.is_empty() else ""
		_sel_curves = keep
		_announce_selection()
		return
	_clear_face_selection()
	if dot == "" or not curve_states().has(dot):
		return
	# _sel_kind stays "": it names an ELEMENT or brush selection, and context_sections
	# offers element ops (move in / out) for any kind that is set.
	_sel_curve = dot
	_sel_curves = [dot]
	print("[MapDCLevel] selected curve %s" % dot)


func selected_curves() -> Array:
	return _sel_curves.duplicate()


## A (2.79's select-all toggle, §17.10): anything selected -> nothing; else every visible
## object, every live brush and every curve, all held at once.
func select_all() -> void:
	if not (_sel_brushes.is_empty() and _sel_objects.is_empty() and _sel_curves.is_empty()):
		_clear_face_selection()
		return
	if level != null and level.has_method("get_live_brush_ids"):
		select_brushes(level.get_live_brush_ids())
	if _objects != null and is_instance_valid(_objects):
		for e: Dictionary in (_objects.entries() as Array):
			var n := e["node"] as Node3D
			if n == null or n.is_visible_in_tree():
				_sel_objects.append(String(e["dot"]))
		_sel_object = _sel_objects[-1] if not _sel_objects.is_empty() else ""
	var cds: Array = curve_states().keys()
	cds.sort()
	for d: String in cds:
		if not _hidden_curves.has(d):
			_sel_curves.append(d)
	var keep := _sel_curves.duplicate()
	_sel_curve = _sel_curves[-1] if not _sel_curves.is_empty() else ""
	_sel_curves = keep
	_announce_selection()
	print("[MapDCLevel] selected all: %d brush(es), %d object(s), %d curve(s)" % [_sel_brushes.size(),
		_sel_objects.size(), _sel_curves.size()])


## DROP TO THE GROUND (#120 L3 §14.3): Cablerator's Drop the Cable. Preloaded, not by class
## name: a new class_name is unknown until the next import refreshes the class cache.
const CurveDropScript := preload("res://scenes/maps/curves/curve_drop.gd")
const DropCableOperatorScript := preload("res://scenes/maps/operators/drop_cable_operator.gd")
## How far down a dropping point looks for ground.
const DROP_REACH := 200.0
var _drop_op: LevelOperator = null


## The selected cables: the active shape and the shapes of the selected brushes, by dot.
func selected_cables() -> Array:
	_ensure_persistence()
	var cands: Array = [_sel_shape] if _sel_shape != "" else []
	for b: int in _sel_brushes:
		cands.append(shape_of_brush(b))
	var out: Array = []
	for d: String in cands:
		if d != "" and not out.has(d) and String(_persist.shape_state(d).get("type", "")) == "cable":
			out.append(d)
	return out


## Cable `dot`'s curve points as Drop would write them (the tool's preview reads this too);
## [] when it is not a cable on a live curve. A point rests the cable's radius above the hit
## — the whole bundle's when it has strands.
func dropped_points(dot: String, step: float) -> Array:
	_ensure_persistence()
	var st: Dictionary = _persist.shape_state(dot)
	if String(st.get("type", "")) != "cable":
		return []
	var cv: Dictionary = _persist.curve_states().get(String((st["params"] as Dictionary).get("curve", "")), {})
	if cv.is_empty():
		return []
	var p: Dictionary = ParamShapes.get_type("cable").resolve(st["params"])
	var rest := float(p["radius"]) * ((1.0 + float(p["spread"])) if int(p["strands"]) > 1 else 1.0)
	var own: Array = []
	for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
		own.append(level.get_brush(b).get("planes", PackedFloat32Array()))
	return CurveDropScript.dropped(cv["points"], bool(cv.get("closed", false)), step, rest,
		func(from: Vector3) -> Dictionary: return _drop_ray(from, own))


## Drop each cable in `dots`: ONE curve.set per cable, all in one gesture. How many wrote.
func drop_cables(dots: Array, step: float = 1.0) -> int:
	_ensure_persistence()
	var targets: Array = []
	for d: Variant in dots:
		var pts := dropped_points(String(d), step)
		if pts.size() >= 2:
			targets.append([String((_persist.shape_state(String(d))["params"] as Dictionary)["curve"]), pts])
	var n := 0
	_persist.begin_gesture("Drop to ground")
	for t: Array in targets:
		if _persist.record("curve.set", {"target": t[0], "points": LevelPersistence.CurveFold.to_wire(t[1])}) != "":
			n += 1
	_persist.end_gesture()
	if n > 0:
		_persist.refold()
		_reselect_shape()
	return n


## Straight down from `from` to the first surface that is not one of `own` (the dropping
## cable's pieces, as plane arrays): {position, normal}, or {} on a miss. Players are
## excluded as for every editor ray. A hit on the cable itself restarts 1 mm below it.
func _drop_ray(from: Vector3, own: Array) -> Dictionary:
	var space := get_world_3d().direct_space_state
	var excl: Array[RID] = []
	var players := get_tree().root.get_node_or_null("Game/Players")
	if players != null:
		for pl in players.get_children():
			if pl is CollisionObject3D:
				excl.append((pl as CollisionObject3D).get_rid())
	var end := from + Vector3.DOWN * DROP_REACH
	var at := from
	for _i in 64:
		var q := PhysicsRayQueryParameters3D.create(at, end)
		q.collide_with_areas = false
		q.exclude = excl
		var hit: Dictionary = space.intersect_ray(q)
		if hit.is_empty():
			return {}
		var pos: Vector3 = hit["position"]
		if not _inside_any(pos, own):
			return {"position": pos, "normal": hit["normal"]}
		at = pos + Vector3.DOWN * 0.001
		if at.y <= end.y:
			return {}
	return {}


## CABLES FROM EDGES (#120 L3 §14.5). Preloaded for the same class-cache reason as Drop.
const CableEdgesOperatorScript := preload("res://scenes/maps/operators/cable_edges_operator.gd")
const EdgeCablesScript := preload("res://scenes/maps/curves/edge_cables.gd")
var _edges_op: LevelOperator = null


## A curve through `points` and a cable shape on it — RECORDS ONLY, inside the caller's
## gesture (add_cable and cables_from_edges write many in one). The shape's dot, or "".
func _record_cable(points: Array, closed: bool, params: Dictionary, auto: bool = true) -> String:
	var pts: Array = CurveMath.auto_handles(points, closed) if auto else points
	var cdot: String = _persist.record("curve.create", {"points": LevelPersistence.CurveFold.to_wire(pts),
		"closed": closed, "name": "Cable"})
	if cdot == "":
		return ""
	var p := params.duplicate(true)
	p["curve"] = cdot
	var t: Object = ParamShapes.get_type("cable")
	return _persist.record("shape.create", {"type": "cable", "gv": t.GV, "params": p,
		"frame": SceneObjects.xform_to(Transform3D.IDENTITY), "size": [1.0, 1.0, 1.0],
		"material_id": 0, "level_id": 0, "tex": current_face_tex()})


## A cable per chain of `edges` (selected_edges()' form), ONE gesture; the new cables become
## the selection. Their dots ([] when there was nothing to make).
func cables_from_edges(edges: Array, centres: bool, offset: float, sharp: bool, params: Dictionary = {}) -> Array:
	_ensure_persistence()
	var out: Array = []
	var chains: Array = EdgeCablesScript.chains(edges)
	if chains.is_empty():
		return out
	_persist.begin_gesture("Cable from edges")
	for ch: Dictionary in chains:
		var pts: Array = EdgeCablesScript.curve_points(ch, centres, offset, sharp)
		if pts.size() < 2:
			continue
		var d := _record_cable(pts, bool(ch["closed"]), params, false)
		if d != "":
			out.append(d)
	_persist.end_gesture()
	if out.is_empty():
		return out
	_persist.refold()
	var ids := PackedInt32Array()
	for d2: String in out:
		ids.append_array(level.get_group_brush_ids(d2) as PackedInt32Array)
	select_brushes(ids)
	return out


## A BUNDLE BETWEEN TWO FACE SETS (#120 L3 §14.6). Preloaded for the class-cache reason.
const CableBundleOperatorScript := preload("res://scenes/maps/operators/cable_bundle_operator.gd")
const BundleCablesScript := preload("res://scenes/maps/curves/bundle_cables.gd")
var _bundle_op: LevelOperator = null


## The bundle on `faces` (_selected_face_polys()' form; exactly two islands) with options `o`
## (BundleCables.bundle): N curves + cables and a "Bundle" collection holding them (under
## Scene), ONE gesture; the cables become the selection. Their dots ([] when refused).
func cable_bundle(faces: Array, o: Dictionary, params: Dictionary = {}) -> Array:
	_ensure_persistence()
	var out: Array = []
	var cables: Array = BundleCablesScript.bundle(faces, o)["cables"]
	if cables.is_empty():
		return out
	_persist.begin_gesture("Cable bundle")
	for pts: Array in cables:
		var d := _record_cable(pts, false, params, false)
		if d != "":
			out.append(d)
	if not out.is_empty():
		var col: String = _persist.record("col.create", {"name": "Bundle", "parent": ""})
		var members: Array = []
		for d2: String in out:
			members.append("g:" + d2)
		if col != "":
			_persist.record("col.link", {"target": col, "members": members})
	_persist.end_gesture()
	if out.is_empty():
		return out
	_persist.refold()
	var ids := PackedInt32Array()
	for d3: String in out:
		ids.append_array(level.get_group_brush_ids(d3) as PackedInt32Array)
	select_brushes(ids)
	return out


## Is `p` inside (or on, within FaceKit.ON_PLANE) any of the convex plane sets?
static func _inside_any(p: Vector3, sets: Array) -> bool:
	for pl: PackedFloat32Array in sets:
		var inside := pl.size() >= 16
		for k in range(0, pl.size(), 4):
			if Vector3(pl[k], pl[k + 1], pl[k + 2]).dot(p) - pl[k + 3] > FaceKit.ON_PLANE:
				inside = false
				break
		if inside:
			return true
	return false


var _curve_view: MeshInstance3D = null
var _curves_drawn: Array = []         # [curve states, hidden dots, selected dot] last drawn


## Every live curve as wire, sampled every ~0.25 m (8 .. 512 samples), its points as dots.
func _refresh_curves() -> void:
	if _persist == null:
		return
	var curves: Dictionary = _persist.curve_states()
	if curves.is_empty() and _curve_view == null:
		return
	if _curve_view == null or not is_instance_valid(_curve_view):
		_curve_view = MeshInstance3D.new()
		_curve_view.name = "CurveView"
		_curve_view.mesh = ImmediateMesh.new()
		_curves_drawn = []                  # a new view has drawn nothing yet
		# PER-SURFACE materials (not material_override, which would paint every surface the
		# same): the selected curve is drawn white (§16.4), the rest gold.
		_curve_mat = _curve_material(Color(1.0, 0.85, 0.3))
		_curve_sel_mat = _curve_material(Color(1.0, 1.0, 1.0))
		_curve_view.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		_curve_view.visible = _curves_visible()
		add_child(_curve_view)
	# NOTHING TO REDRAW when what is drawn has not changed: the curves, which are hidden,
	# which is selected. Every fold called this, and with a cable bundle it re-sampled
	# every cable's curve to draw the same wire (48 ms per fold at 40 cables, 2026-10-20).
	var drawn := [curves, _hidden_curves.keys(), _sel_curve]
	if drawn == _curves_drawn and _curve_view.mesh != null:
		return
	_curves_drawn = drawn.duplicate(true)
	var im: ImmediateMesh = _curve_view.mesh
	im.clear_surfaces()
	for dot: String in curves:
		if _hidden_curves.has(dot):
			continue                             # the eye / H (§16.4)
		var c: Dictionary = curves[dot]
		var mat: Material = _curve_sel_mat if dot == _sel_curve else _curve_mat
		var pts: Array = c["points"]
		var n := clampi(int(CurveMath.length(pts, bool(c["closed"])) / 0.25), 8, 512)
		var fs := CurveMath.sample(pts, bool(c["closed"]), n)
		if fs.size() < 2:
			continue
		im.surface_begin(Mesh.PRIMITIVE_LINE_STRIP, mat)
		for f: Dictionary in fs:
			im.surface_add_vertex(f["p"])
		if bool(c["closed"]):
			im.surface_add_vertex(fs[0]["p"])
		im.surface_end()
		im.surface_begin(Mesh.PRIMITIVE_POINTS, mat)
		for q: Dictionary in pts:
			im.surface_add_vertex(q["p"])
		im.surface_end()
		# HANDLES of every non-auto point (§6.1): a line to each end, the ends as dots.
		var hl := PackedVector3Array()
		for q2: Dictionary in pts:
			if CurveMath.handle_type(q2) == "auto":
				continue
			var p2: Vector3 = q2["p"]
			for hk: String in ["h_in", "h_out"]:
				hl.append(p2)
				hl.append(p2 + (q2[hk] as Vector3))
		if not hl.is_empty():
			im.surface_begin(Mesh.PRIMITIVE_LINES, mat)
			for v: Vector3 in hl:
				im.surface_add_vertex(v)
			im.surface_end()
			im.surface_begin(Mesh.PRIMITIVE_POINTS, mat)
			for i in range(1, hl.size(), 2):
				im.surface_add_vertex(hl[i])
			im.surface_end()


var _curve_mat: Material = null
var _curve_sel_mat: Material = null


func _curve_material(col: Color) -> StandardMaterial3D:
	var m := StandardMaterial3D.new()
	m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	m.albedo_color = col
	m.no_depth_test = true
	m.use_point_size = true
	m.point_size = 7.0
	return m


## How many surfaces draw the SELECTED curve (gates).
func curve_view_selected_surfaces() -> int:
	if _curve_view == null or not is_instance_valid(_curve_view):
		return 0
	var im := _curve_view.mesh as ImmediateMesh
	var n := 0
	for i in im.get_surface_count():
		if im.surface_get_material(i) == _curve_sel_mat:
			n += 1
	return n


## The number of curves drawn (gates).
func curve_view_surfaces() -> int:
	return (_curve_view.mesh as ImmediateMesh).get_surface_count() if _curve_view != null and is_instance_valid(_curve_view) else 0


## ---- MODIFIERS (#120 F2, ModFold) ----------------------------------------------
## A shape's copy modifiers: mirror / array / radial / curve, each one gesture. Defaults:
## mirror along the frame's x at its low face (the copy alongside), array of 2 along x,
## radial of 4 about the frame's up axis through the 3D cursor, curve = follow the curve
## nearest the shape, fitted (count 0).
const MOD_DEFAULTS := {
	"mirror": {"axis": "x", "offset": 0.0},
	"array": {"count": 2, "offset": [1.0, 0.0, 0.0]},
	"grid": {"count": [2, 1, 2], "offset": [1.0, 1.0, 1.0]},
	"dice": {"count": [2, 1, 2]},
	"radial": {"count": 4, "axis": "y"},
	"curve": {"count": 0},
	"chamfer": {"width": 0.1, "segments": 1, "angle": 30.0},
}
## The letter that picks each kind in Add modifier (Shift+Ctrl+M, then the letter; §12.5).
## C is curve, so chamfer takes B (BoxCutter's bevel key).
const MOD_KEYS := {"mirror": KEY_M, "array": KEY_A, "grid": KEY_G, "radial": KEY_R,
	"curve": KEY_C, "chamfer": KEY_B, "dice": KEY_D}


func add_modifier(kind: String, shape: String = "", params: Dictionary = {}) -> String:
	_ensure_persistence()
	if shape == "":
		shape = _sel_shape
	if shape == "" or not MOD_DEFAULTS.has(kind) or not (_persist.live_shapes() as Dictionary).has(shape):
		print("[MapDCLevel] add modifier: select a shape first (Make parametric turns a brush into one)")
		return ""
	var p: Dictionary = (MOD_DEFAULTS[kind] as Dictionary).duplicate(true)
	if kind == "radial":
		var c: Vector3 = (_cursor3d as Node3D).global_position if _cursor3d is Node3D else Vector3.ZERO
		p["pivot"] = [c.x, c.y, c.z]
	p.merge(params, true)
	if kind == "curve" and String(p.get("curve", "")) == "":
		p["curve"] = _nearest_curve(shape)
		if String(p["curve"]) == "":
			print("[MapDCLevel] add modifier: a curve modifier needs a curve (Add → Curve)")
			return ""
	_persist.begin_gesture("Modifier: %s" % kind.capitalize())
	var dot: String = _persist.record("mod.create", {"target": shape, "mod": kind, "params": p})
	_persist.end_gesture()
	_persist.refold()
	select_shape(shape)
	return dot


## The live curve with a point nearest the shape's centre ("" when the room has none).
func _nearest_curve(shape: String) -> String:
	var st: Dictionary = _persist.shape_state(shape)
	var c: Vector3 = (st["frame"] as Transform3D) * ((st["size"] as Vector3) * 0.5) if not st.is_empty() else Vector3.ZERO
	var best := INF
	var out := ""
	var curves: Dictionary = _persist.curve_states()
	var dots: Array = curves.keys()
	dots.sort()                                    # a tie goes the same way on every peer
	for d: String in dots:
		for q: Dictionary in curves[d]["points"]:
			var dd := (q["p"] as Vector3).distance_to(c)
			if dd < best:
				best = dd
				out = d
	return out


func set_modifier(dot: String, params: Dictionary = {}, on: Variant = null) -> void:
	_ensure_persistence()
	var payload := {"target": dot}
	if not params.is_empty():
		payload["params"] = params
	if on != null:
		payload["on"] = bool(on)
	_persist.begin_gesture("Edit modifier")
	_persist.record("mod.set", payload)
	_persist.end_gesture()
	_persist.refold()


func remove_modifier(dot: String) -> void:
	_ensure_persistence()
	_persist.begin_gesture("Remove modifier")
	_persist.record("mod.remove", {"target": dot})
	_persist.end_gesture()
	_persist.refold()


## The shape's modifiers that still exist (on or off), in stack order (ModFold states).
func modifier_stack(shape: String) -> Array:
	_ensure_persistence()
	return LevelPersistence.ModFold.stack_of(_persist.records(false), shape)


## The generator picker's rows for the selected shape: every create-tool type, plus the
## input's own type (which removes the generator) when one is assigned.
func generator_choices() -> Array:
	var rows: Array = []
	var dot := _sel_shape
	if dot == "":
		return rows
	var st: Dictionary = _persist.shape_state(dot)
	var inp := String((st.get("input", {}) as Dictionary).get("type", ""))
	if inp != "":
		var ti: Object = ParamShapes.get_type(inp)
		rows.append(["Remove generator (back to %s)" % (ti.label() if ti != null else inp),
			func() -> void: assign_generator(dot, inp)])
	for tid: String in ParamShapes.ids():
		if tid == String(st.get("type", "")) or tid == inp:
			continue                           # inp: that row is "Remove generator"
		var t: Object = ParamShapes.get_type(tid)
		rows.append([t.label(), func() -> void: assign_generator(dot, tid)])
	return rows


## MAKE REAL (§6, "Apply modifier"): the shape's current pieces become plain brushes —
## brush.create records with fresh dots, one new group — and the shape stops generating
## (shape.detach). ONE gesture: Ctrl+Z brings the shape back. Returns the piece count.
func make_real(dot: String) -> int:
	_ensure_persistence()
	if not (_persist.live_shapes() as Dictionary).has(dot):
		return 0
	# A LIVE CUTTER is a tool: baking it would turn the wire into solid brushes standing
	# in the hole it cuts, and leave its op cutting with nothing.
	if LevelPersistence.OpFold.cutter_dots(_persist.records(false)).has(dot):
		print("[MapDCLevel] %s is a live cutter — Make Real its TARGET (or undo the cut) instead" % dot)
		return 0
	var ids: PackedInt32Array = level.get_group_brush_ids(dot)
	_persist.begin_gesture("Make Real")
	var group := ""
	var n := 0
	last_real.clear()
	for id: int in ids:
		var payload: Dictionary = _persist._brush_payload(level.get_brush(id))
		var nd: String = _persist.reserve_dot()
		if group == "":
			group = nd
		payload["group"] = group
		if _persist.record_at(nd, "brush.create", payload):
			n += 1
			last_real[id] = nd
	_persist.record("shape.detach", {"target": dot})
	_retire_ops_on(dot)                 # its cuts are baked into the pieces above
	_persist.end_gesture()
	if _sel_shape == dot:
		_sel_shape = ""
	_persist.refold()
	select_brushes(level.get_group_brush_ids(group))
	print("[MapDCLevel] made %s real: %d brush(es)" % [dot, n])
	return n


func select_brushes(ids: PackedInt32Array) -> void:
	_sel_shape = ""
	_sel_asset = ""
	_sel_curve = ""
	_sel_object = ""                       # one selection at a time; see _sel_object
	_sel_objects.clear()
	_announce_selection()
	_sel_brushes.clear()
	for b: int in ids:
		_sel_brushes.append(b)
	_sel_brush = _sel_brushes[-1] if not _sel_brushes.is_empty() else -1
	# A multi-brush selection has no single face or element, so the per-element
	# highlight is cleared rather than left pointing at whatever was picked last.
	_sel_keys = PackedStringArray()
	_sel_faces.clear()
	_sel_edges.clear()
	# "brush", NOT "". MoveOperator gates its selection branch on
	# `_sel_kind == "brush"` (move_operator.gd on_activate), so an empty kind made
	# G ignore the outliner entirely and fall through to the crosshair — which
	# refuses outright when you are not aiming at geometry. Selecting a passage
	# and pressing G did nothing, which is exactly what it looked like.
	_sel_kind = "brush" if not _sel_brushes.is_empty() else ""
	# WHERE THE DRAG GRABS. The centre of what was selected, because a selection
	# made from a panel has no click point — and MoveOperator measures its drag
	# from _sel_point, so leaving it stale grabs wherever you last clicked and the
	# first frame jumps.
	_sel_point = _selection_centre()
	_update_face_highlight()
	_update_element_highlight()
	var v := _ensure_brush_view()
	v.set_aimed(_sel_brush)
	print("[MapDCLevel] outliner selected %d brush(es), active #%d"
		% [_sel_brushes.size(), _sel_brush])


## MOVE EVERY SELECTED BRUSH, journalled, one rebuild at the end.
##
## Extracted from MoveOperator.confirm so the operator and the organizer's
## numeric fields drive the SAME path. Two writers of a journal path is how they
## come to disagree about what a record means — and this one already has two
## callers on the day it is written.
##
## Returns how many actually moved: the store refuses a translation that would
## empty a brush, and a partial move is reported rather than rolled back, for the
## same reason CreateBoxOperator reports a partial create.
## ROTATE `ids` by `deg` about `axis` through ONE shared `pivot` (RotateOperator).
## One brush.rotate per brush, pivot carried in the record for the same reason as
## scale_selection's: a replaying peer must turn the group about the author's point.
## Texture lock as everywhere (libdc freezes locked faces to explicit). One rebuild.
func rotate_brushes(ids: Array, axis: Vector3, deg: float, pivot: Vector3) -> int:
	if ids.is_empty() or level == null or is_zero_approx(deg):
		return 0
	_ensure_persistence()
	# A SHAPE turns its FRAME about the pivot (shape.xform); its pieces regenerate.
	var sp: Array = _split_shapes(ids)
	# A PLACED ASSET turns its frame the same way (asset.xform, #120 L4).
	if not (sp[2] as Dictionary).is_empty():
		var ra := Transform3D(Basis(axis.normalized(), deg_to_rad(deg)), Vector3.ZERO)
		var about_a := Transform3D(Basis.IDENTITY, pivot) * ra * Transform3D(Basis.IDENTITY, -pivot)
		var na := 0
		for pd: String in (sp[2] as Dictionary):
			if _record_asset_frame(pd, about_a * _asset_frame(pd)):
				na += 1
		if (sp[0] as Dictionary).is_empty() and (sp[1] as Array).is_empty():
			return _shapes_transformed(na)
		_shapes_transformed(na)
	ids = sp[1]                                # plain brushes only below: never a placement's piece
	if not (sp[0] as Dictionary).is_empty():
		var r := Transform3D(Basis(axis.normalized(), deg_to_rad(deg)), Vector3.ZERO)
		var about := Transform3D(Basis.IDENTITY, pivot) * r * Transform3D(Basis.IDENTITY, -pivot)
		var ns := 0
		for d: String in (sp[0] as Dictionary):
			var st: Dictionary = _persist.shape_state(d)
			if _record_shape_frame(d, about * (st["frame"] as Transform3D)):
				ns += 1
		if (sp[1] as Array).is_empty():
			return _shapes_transformed(ns)
		_shapes_transformed(ns)
		ids = sp[1]
	var turned := 0
	for b: int in ids:
		if not level.rotate_brush(b, axis, deg, pivot, tex_lock):
			continue
		_persist.record("brush.rotate", {
			"brush_key": level.get_brush_key(b),
			"axis": [axis.x, axis.y, axis.z],
			"angle": deg,
			"pivot": [pivot.x, pivot.y, pivot.z],
			"tex_lock": tex_lock,
		})
		turned += 1
	if turned == 0:
		return 0
	level.rebuild()
	_refresh_brush_view()
	_sel_point = _selection_centre()
	return turned


func translate_selection(delta: Vector3) -> int:
	if _sel_brushes.is_empty() or level == null or delta == Vector3.ZERO:
		return 0
	_ensure_persistence()
	# A SHAPE moves its FRAME (shape.xform); its pieces regenerate where it now is.
	var ids: Array = _sel_brushes.duplicate()
	var sp: Array = _split_shapes(ids)
	# A PLACED ASSET moves its frame too (asset.xform, #120 L4).
	var na := 0
	for pd: String in (sp[2] as Dictionary):
		var fa := _asset_frame(pd)
		if _record_asset_frame(pd, Transform3D(fa.basis, fa.origin + delta)):
			na += 1
	if na > 0 and (sp[0] as Dictionary).is_empty() and (sp[1] as Array).is_empty():
		return _shapes_transformed(na)
	ids = sp[1]                                # plain brushes only below: never a placement's piece
	if not (sp[0] as Dictionary).is_empty():
		var ns := 0
		for d: String in (sp[0] as Dictionary):
			var f: Transform3D = _persist.shape_state(d)["frame"]
			if _record_shape_frame(d, Transform3D(f.basis, f.origin + delta)):
				ns += 1
		if (sp[1] as Array).is_empty():
			return _shapes_transformed(ns)
		var keep_shape := _sel_shape
		_shapes_transformed(ns)
		_sel_shape = keep_shape
		ids = sp[1]
	var moved := 0
	for b: int in ids:
		if not level.translate_brush(b, delta, tex_lock):
			continue
		_persist.record("brush.translate", {
			"brush_key": level.get_brush_key(b),
			"delta": [delta.x, delta.y, delta.z],
			"tex_lock": tex_lock,
		})
		moved += 1
	if moved == 0:
		return 0
	level.rebuild()
	_refresh_brush_view()
	_sel_point = _selection_centre()
	return moved


## SCALE EVERY SELECTED BRUSH about one shared pivot — hyper-games #28, "if the
## length changes the cut should change with it".
##
## THE PIVOT IS SHARED AND PASSED, not each brush's own centre. Scaling every
## piece about itself leaves them the same distance apart and the arch comes
## apart; about the selection's centre they stay one object. It is journalled
## with the record for the same reason: a replaying peer must scale about the
## point the author used, and "the group's centre" is a function of the whole
## selection rather than of any one brush.
##
## THE CUT FOLLOWS BY CONSTRUCTION. A clearance volume is in the group, so it is
## scaled by the identical factor about the identical pivot — the ring cannot get
## longer than the bore that serves it. That is the whole reason this is a group
## operation rather than a per-brush one.
func scale_selection(factor: Vector3, pivot: Vector3) -> int:
	return scale_brushes(_sel_brushes, factor, pivot)


## LOCAL SCALE (#109, user 2026-10-13: Scale's X X; "local = shapes only"): each shape's
## SIZE scales by `f` along its OWN axes, and its frame origin moves by M = B·S·Bᵀ about
## the pivot — exact for any frame, turned or not. A plain brush cannot take this (a
## world-axis brush.scale is all the store has), so ScaleOperator only offers local on
## shapes; plain pieces in `ids` are left alone here.
func scale_shapes_local(ids: Array, f: Vector3, pivot: Vector3) -> int:
	if ids.is_empty() or f.is_equal_approx(Vector3.ONE) or f.x <= 0.0 or f.y <= 0.0 or f.z <= 0.0:
		return 0
	_ensure_persistence()
	var sp: Array = _split_shapes(ids)
	if not (sp[2] as Dictionary).is_empty():
		print("[MapDCLevel] scale: a placed asset is rigid (#120 L4 §15.2) — left as it is; Make Real it to scale its parts")
	var ns := 0
	for d: String in (sp[0] as Dictionary):
		var st: Dictionary = _persist.shape_state(d)
		var fr: Transform3D = st["frame"]
		var size: Vector3 = st["size"]
		var b := fr.basis.orthonormalized()
		var m: Basis = b * Basis.from_scale(f) * b.inverse()
		var o: Vector3 = pivot + m * (fr.origin - pivot)
		if _record_shape_frame(d, Transform3D(fr.basis, o), size * f):
			ns += 1
	return _shapes_transformed(ns)


## The SIZE of the shape a brush is a piece of (its own axes), or ZERO for a plain brush.
func shape_local_size(b: int) -> Vector3:
	var sd := shape_of_brush(b)
	if sd == "":
		return Vector3.ZERO
	var st: Dictionary = _persist.shape_state(sd)
	return st.get("size", Vector3.ZERO)


## scale_selection's body for ANY set of brushes — ScaleOperator scales an aimed brush
## that is not selected, exactly as RotateOperator turns one (rotate_brushes).
func scale_brushes(ids: Array, factor: Vector3, pivot: Vector3) -> int:
	if ids.is_empty() or level == null:
		return 0
	if absf(factor.x) < 1.0e-4 or absf(factor.y) < 1.0e-4 or absf(factor.z) < 1.0e-4:
		return 0                           # a zero factor is not undoable by scaling back
	if factor.is_equal_approx(Vector3.ONE):
		return 0
	_ensure_persistence()
	# A SHAPE scales by its SIZE (shape.size) and frame origin (shape.xform) — exact for
	# an AXIS-ALIGNED frame: the corner maps to pivot + f*(corner - pivot) and each local
	# extent scales by the factor of the world axis it lies on. A turned frame cannot
	# take a non-uniform world scale as a size; it is refused, not approximated.
	var sp: Array = _split_shapes(ids)
	if not (sp[2] as Dictionary).is_empty():
		print("[MapDCLevel] scale: a placed asset is rigid (#120 L4 §15.2) — left as it is; Make Real it to scale its parts")
	ids = sp[1]                                # plain brushes only below: never a placement's piece
	if not (sp[0] as Dictionary).is_empty():
		var ns := 0
		for d: String in (sp[0] as Dictionary):
			var st: Dictionary = _persist.shape_state(d)
			var f: Transform3D = st["frame"]
			var size: Vector3 = st["size"]
			var new_size := size
			var aligned := factor.x > 0.0 and factor.y > 0.0 and factor.z > 0.0
			for i in 3:
				var col: Vector3 = f.basis[i]
				var w := -1
				for j in 3:
					if absf(absf(col[j]) - col.length()) < 1.0e-4:
						w = j
				if w < 0:
					aligned = false
					break
				new_size[i] = size[i] * factor[w]
			if not aligned:
				print("[MapDCLevel] shape %s is turned off-axis — Make Real to scale its pieces" % d)
				continue
			var o: Vector3 = pivot + (f.origin - pivot) * factor
			if _record_shape_frame(d, Transform3D(f.basis, o), new_size):
				ns += 1
		if (sp[1] as Array).is_empty():
			return _shapes_transformed(ns)
		_shapes_transformed(ns)
		ids = sp[1]
	var scaled := 0
	for b: int in ids:
		# A refusal is per brush and is not fatal: the store rejects a scale that
		# would leave a brush enclosing nothing, and reporting a partial is better
		# than rolling back geometry other peers may already have replayed.
		if not level.scale_brush(b, factor, pivot, tex_lock):
			continue
		# FLAT FLOATS, like every other vector in this journal (`delta`, `pos`,
		# `half`). An engine type in a durable record is one more thing that has
		# to keep surviving var_to_str across builds.
		_persist.record("brush.scale", {
			"brush_key": level.get_brush_key(b),
			"scale": [factor.x, factor.y, factor.z],
			"pivot": [pivot.x, pivot.y, pivot.z],
			"tex_lock": tex_lock,
		})
		scaled += 1
	if scaled == 0:
		return 0
	level.rebuild()
	_refresh_brush_view()
	_sel_point = _selection_centre()
	return scaled


## One grid step in metres — public, because the organizer ratchets its size
## fields to it and reaching into _grid_step from a panel is how the two drift.
func grid_step() -> float:
	return _grid_step()


## The grid, in QUAKE UNITS — 8, 16, 32… This is the vocabulary the rest of the
## editor already speaks: the create tool's status line reads "152 units" and the
## .MAP was authored on an integer lattice of them. Metres are the engine's
## internal scale and nobody building a level thinks in 0.224 of one.
##
## THE EFFECTIVE STEP (E2): every operator and placement snap reads this, so the snap
## toggle, Ctrl-invert and Shift-precision reach all of them through one function.
func grid_units() -> float:
	var u: float = base_grid_units() if snap_active() else 1.0
	if _tool_up() and Input.is_key_pressed(KEY_SHIFT):
		u /= PRECISION
	return u


## Metres per Quake unit. Exposed so the organizer can show units without
## hard-coding the constant a second time.
func world_scale() -> float:
	return WORLD_SCALE


## The selection's bounding box, for the organizer's size fields.
## One narrow read (DCLevel.get_brushes_bounds), not a get_brush per brush: the organizer
## asks on every fold, and a 668-brush selection cost ~80 ms that way (#127).
func selection_size() -> Vector3:
	if _sel_brushes.is_empty() or level == null:
		return Vector3.ZERO
	var bb: Dictionary = level.get_brushes_bounds(PackedInt32Array(_sel_brushes))
	return ((bb["aabb_max"] as Vector3) - (bb["aabb_min"] as Vector3)) if int(bb["count"]) > 0 else Vector3.ZERO


## Select every live brush in the level — the organizer's Select all.
func select_all_brushes() -> void:
	if level == null or not level.has_method("get_live_brush_ids"):
		return
	select_brushes(level.get_live_brush_ids())


## The centre of the current brush selection — public, because the organizer's
## position fields read it and write back a delta against it.
func selection_centre() -> Vector3:
	return _selection_centre()


## The centre of the current brush selection, for a drag that began in a panel.
func _selection_centre() -> Vector3:
	if _sel_brushes.is_empty() or level == null:
		return Vector3.ZERO
	# The mean of the brushes' centres, as get_brush's "center" summed (#127: one read).
	return level.get_brushes_bounds(PackedInt32Array(_sel_brushes))["center"]


func _clear_face_selection() -> void:
	_sel_shape = ""
	_sel_asset = ""
	_sel_curve = ""
	_sel_object = ""
	_sel_objects.clear()
	_announce_selection()
	_sel_brushes.clear()
	_sel_brush = -1
	_sel_keys = PackedStringArray()
	_sel_faces.clear()
	_sel_edges.clear()
	_sel_kind = ""
	_update_face_highlight()
	_update_element_highlight()
	print("[MapDCLevel] selection cleared")


## Add or replace, and make it active. Clicking a brush that is already in the set
## with `add` REMOVES it — the same toggle every editor uses, so a mis-shift-click is
## undone by repeating it rather than by starting the selection over.
func _note_selected(brush: int, add: bool) -> void:
	# ONE SELECTION AT A TIME (see _sel_object): picking a brush drops a selected
	# object. It did not — select an object in the Outliner, click a wall, press G,
	# and the OBJECT moved, because MoveOperator checks _sel_object first.
	_announce_selection()
	if not add:
		_sel_shape = ""
		_sel_asset = ""
		_sel_curve = ""
		_sel_object = ""
		_sel_objects.clear()
		_sel_brushes = [brush]
		_sel_brush = brush
		return
	# THE UNION (§17.10): Shift keeps the objects and curves already held (a shape or a
	# placement held as ONE thing becomes plain brushes once one is toggled).
	_sel_shape = ""
	_sel_asset = ""
	var at: int = _sel_brushes.find(brush)
	if at >= 0:
		_sel_brushes.remove_at(at)
		# Deselecting the active one hands active to whatever is left, or to nothing.
		if _sel_brush == brush:
			_sel_brush = _sel_brushes[-1] if not _sel_brushes.is_empty() else -1
		return
	_sel_brushes.append(brush)
	_sel_brush = brush


## Which planes contain this point? That IS the incidence test — no adjacency
## structure is needed, because a face/edge/vertex simply is the set of planes
## whose surface the point lies on.
func _planes_through(brush: int, p: Vector3, eps: float) -> PackedInt32Array:
	var out := PackedInt32Array()
	var pl: PackedFloat32Array = level.get_brush(brush).get("planes", PackedFloat32Array())
	for i in range(pl.size() / 4):
		if not level.face_is_live(brush, i):
			continue
		var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
		if absf(n.dot(p) - pl[i * 4 + 3]) < eps:
			out.append(i)
	return out


## The selected face, drawn. A print is not a selection — you cannot aim at a
## wall and know which of its faces the editor thinks you picked, and with the
## nudge moving one plane at a time that ambiguity is the whole interaction.
##
## Parented to the DCLevel node, not to the map: the winding comes back in the
## store's own space, so anything else would need the transform applied by hand
## and would drift the moment the level node is moved.
func _ensure_highlight() -> MeshInstance3D:
	if _hl != null and is_instance_valid(_hl):
		return _hl
	_hl = MeshInstance3D.new()
	_hl.name = "FaceHighlight"
	_hl.mesh = ImmediateMesh.new()
	var m := StandardMaterial3D.new()
	m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
	m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
	# A LIGHT, DEPTH-TESTED tint (docs/editor-texturing-selection-move.md §3). It was
	# cyan 0.35, double-sided and drawn through walls — ~58 % over the texture on a
	# box, so the thing being edited could not be seen. The outline (SelectionOutline)
	# carries the selection now; this only marks WHICH face, and leaves it readable.
	# Coplanar z-fighting is avoided by lifting the polygon 1 mm off the face
	# (_update_face_highlight), not by switching depth testing off.
	m.albedo_color = Color(1.0, 0.72, 0.25, 0.12)
	m.cull_mode = BaseMaterial3D.CULL_DISABLED
	m.no_depth_test = false
	_hl.material_override = m
	_hl.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
	_hl.visible = _highlights_on()
	level.add_child(_hl)
	return _hl


## The SELECTED ELEMENT, in its own shape: a point for a vertex, a line for an edge,
## the polygon for a face.
##
## The incident-face wash below is still drawn, and is still honest — it is what a
## nudge will move, all of it, bodily. But it answered the wrong question on its own:
## selecting a corner lit up three faces, so "vertex" and "face" looked identical and
## the only way to know which you had was to read a print. This draws the thing you
## picked, over the thing it will move.
func _update_element_highlight() -> void:
	if level == null or not is_instance_valid(level):
		return
	if _sel_elem == null or not is_instance_valid(_sel_elem):
		_sel_elem = MeshInstance3D.new()
		_sel_elem.name = "SelectedElement"
		_sel_elem.mesh = ImmediateMesh.new()
		var m := StandardMaterial3D.new()
		m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
		m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
		m.albedo_color = Color(0.2, 1.0, 0.6, 1.0)
		m.no_depth_test = true          # you must be able to see what you selected
		m.use_point_size = true
		m.point_size = 11.0             # bigger than a handle: this one is CHOSEN
		_sel_elem.material_override = m
		_sel_elem.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		_sel_elem.visible = _highlights_on()
		level.add_child(_sel_elem)
	var im: ImmediateMesh = _sel_elem.mesh
	im.clear_surfaces()
	var ol := _ensure_outline()
	for layer: String in ["active", "selected", "face", "element"]:
		ol.clear_layer(layer)
	if _sel_brush < 0 or _sel_kind == "":
		return
	match _sel_kind:
		"vertex":
			im.surface_begin(Mesh.PRIMITIVE_POINTS)
			im.surface_add_vertex(_sel_point)
			im.surface_end()
		"edge":
			# Every edge in the set (§14.4); the active one alone when the set is empty.
			var segs := edge_set_lines()
			if segs.is_empty():
				segs = PackedVector3Array([_sel_edge_a, _sel_edge_b])
			ol.set_layer("element", segs, Color(0.2, 1.0, 0.6), 3.0, 0.4, 1.5)
		"brush":
			# OUTLINES ARE THE WHOLE GRAMMAR (docs/editor-texturing-selection-move.md §3):
			# every selected brush outlined orange; the ACTIVE one — the operand, the
			# CSG target — brighter and thicker. No fill anywhere.
			var others := PackedVector3Array()
			for sb: int in _sel_brushes:
				if sb != _sel_brush:
					others.append_array(level.get_brush_edges_of(sb))
			ol.set_layer("selected", others, OUTLINE_SELECTED, 2.0, 0.3, 1.0)
			ol.set_layer("active", level.get_brush_edges_of(_sel_brush), OUTLINE_ACTIVE, 3.0, 0.35, 1.5)
		"face":
			# The face's boundary, thick, plus the light tint from _update_face_highlight.
			var face: int = level.face_by_key(_sel_brush, _sel_keys[0]) if not _sel_keys.is_empty() else -1
			if face < 0:
				return
			var pts: PackedVector3Array = level.get_face_winding(_sel_brush, face)
			if pts.size() < 3:
				return
			var seg := PackedVector3Array()
			for i in range(pts.size()):
				seg.append(pts[i])
				seg.append(pts[(i + 1) % pts.size()])
			ol.set_layer("face", seg, OUTLINE_FACE, 3.0, 0.35, 1.5)


## Selection colours — Blender / Godot's orange family (user decision 2026-10-03).
## THE SELECTED OBJECT, outlined like a brush: a box at its world bounds (a model's
## meshes, or the small stand-in box a light gets — SceneObjects.bounds_of). Polled,
## because an object moves without the selection changing (G / R preview, a peer's
## edit, the model arriving), and redrawn only when the box actually changes.
## Layer "object" = the ACTIVE object (bright, thick); "objects" = the rest of the
## selection (the brush selection's colour) — the same two-tone rule brushes use.
var _obj_outline_sig := ""


func _update_object_outline() -> void:
	var active := PackedVector3Array()
	var others := PackedVector3Array()
	var sig := ""
	if _outline != null or not _sel_objects.is_empty():
		for d: String in _sel_objects:
			var n := object_node(d) as Node3D
			if n == null:
				continue
			var box := SceneObjects.bounds_of(n)
			sig += "%s%s;" % [d, box]
			var seg := _box_segments(box)
			if d == _sel_object:
				active = seg
			else:
				others.append_array(seg)
	if sig == _obj_outline_sig:
		return
	_obj_outline_sig = sig
	var ol := _ensure_outline()
	ol.set_layer("object", active, OUTLINE_ACTIVE, 3.0, 0.35, 1.5)
	ol.set_layer("objects", others, OUTLINE_SELECTED, 2.0, 0.3, 1.0)


## A world AABB as 12 edges in the outline's space.
func _box_segments(box: AABB) -> PackedVector3Array:
	var inv := _ensure_outline().global_transform.affine_inverse()
	var c: Array = []
	for i in 8:
		c.append(inv * (box.position + box.size * Vector3(i & 1, (i >> 1) & 1, (i >> 2) & 1)))
	var seg := PackedVector3Array()
	for e: Array in [[0, 1], [2, 3], [4, 5], [6, 7], [0, 2], [1, 3], [4, 6], [5, 7], [0, 4], [1, 5], [2, 6], [3, 7]]:
		seg.append(c[e[0]])
		seg.append(c[e[1]])
	return seg


const OUTLINE_SELECTED := Color("#ed5700")
const OUTLINE_ACTIVE := Color("#ffa028")
const OUTLINE_FACE := Color("#ffb700")
var _outline: SelectionOutline = null


func _ensure_outline() -> SelectionOutline:
	if _outline != null and is_instance_valid(_outline):
		return _outline
	_outline = SelectionOutline.new()
	_outline.name = "SelectionOutline"
	_outline.visible = _highlights_on()
	level.add_child(_outline)
	return _outline


## HIGHLIGHTS ARE AN EDITING AID (2026-10-05: "upon exiting editor mode no element
## highlight should remain"). Leaving build mode hid the wireframe but left the orange
## selection outlines, the selected element and the face tint drawn over the world you
## went back to play in. HIDDEN, not cleared: the selection is kept, so coming back to
## build shows what you had. A node made while playing starts hidden (_highlights_on).
func _highlights_on() -> bool:
	return GameMode == null or GameMode.current == GameMode.BUILD


func _show_highlights(on: bool) -> void:
	for n: Variant in [_outline, _sel_elem, _hl]:
		if n is Node3D and is_instance_valid(n):
			(n as Node3D).visible = on


var _sel_elem: MeshInstance3D = null


func _update_face_highlight() -> void:
	if level == null or not is_instance_valid(level):
		return
	var hl := _ensure_highlight()
	var im: ImmediateMesh = hl.mesh
	im.clear_surfaces()
	if _sel_brush < 0 or _sel_keys.is_empty() or not level.has_method("get_face_winding"):
		return
	# ONLY WHERE A FACE IS WHAT YOU PICKED. The wash lights every incident face, so a
	# vertex lit three and an edge lit two — which is honest about what a nudge MOVES
	# but answers a question you did not ask, and drowns the element you actually
	# selected. Vertex mode shows a vertex, edge mode an edge; nothing else.
	# BRUSHES ARE OUTLINED, NEVER WASHED: a brush selection's planes are every face of
	# it, and filling them is exactly what hid the brush (user 2026-10-03).
	if _sel_kind != "face":
		return
	var planes: PackedFloat32Array = level.get_brush(_sel_brush).get("planes", PackedFloat32Array())
	# EVERY incident face, because the selection IS its incident planes: a vertex
	# lights three faces, an edge two, a face one. That is also the honest preview
	# of what a nudge will move — all of them, bodily.
	im.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
	var drawn := 0
	for k: String in _sel_keys:
		var face: int = level.face_by_key(_sel_brush, k)
		if face < 0:
			continue
		var pts: PackedVector3Array = level.get_face_winding(_sel_brush, face)
		if pts.size() < 3:
			continue
		# 1 mm off the face along its outward normal, so the depth-tested tint never
		# z-fights with the surface it marks.
		var lift := Vector3.ZERO
		if face * 4 + 2 < planes.size():
			lift = Vector3(planes[face * 4], planes[face * 4 + 1], planes[face * 4 + 2]).normalized() * 0.001
		# A triangle fan is EXACT here, not an approximation: a brush face is the
		# intersection of half-spaces and is therefore convex by construction.
		for i in range(1, pts.size() - 1):
			im.surface_add_vertex(pts[0] + lift)
			im.surface_add_vertex(pts[i] + lift)
			im.surface_add_vertex(pts[i + 1] + lift)
		drawn += 1
	# The rest of the cross-brush face set (the active face is drawn above; drawing it
	# twice would double its alpha and make it read as special).
	var active_face: int = level.face_by_key(_sel_brush, _sel_keys[0]) if _sel_keys.size() == 1 else -1
	for p: Dictionary in _selected_face_polys():
		if int(p["brush"]) == _sel_brush and int(p["face"]) == active_face:
			continue
		var fp: PackedVector3Array = p["pts"]
		var fl: Vector3 = (p["n"] as Vector3) * 0.001
		for i in range(1, fp.size() - 1):
			im.surface_add_vertex(fp[0] + fl)
			im.surface_add_vertex(fp[i] + fl)
			im.surface_add_vertex(fp[i + 1] + fl)
		drawn += 1
	im.surface_end()
	if drawn == 0:
		im.clear_surfaces()


## Resolve the crosshair to a brush and to the ELEMENT under it — vertex, edge or
## face — by proximity, so one aim serves all three. Cepero's generality
## principle ("a single tool... used to perform many different tasks") rather than
## TrenchBroom's separate V/E/F tools, which cost three keys we do not have.
##
## The snap radius is a fraction of the grid step, so what you get scales with how
## precisely you are working: on a coarse grid corners grab from further away.
## `add` extends the selection instead of replacing it, and the newly picked brush
## becomes ACTIVE either way — last clicked wins, which is the only rule anyone has to
## remember for "which one is the operand".
func _pick_element(add: bool = false, at: Variant = null) -> void:
	# THE BOT EDITOR PICKS BOTS ONLY: no brushes, no other objects.
	if in_bot_editor():
		var bp := _pick_object(at, "bot")
		if not bp.is_empty():
			select_object(String(bp["dot"]), add)
		return
	var hit := _crosshair_hit(at)
	# OBJECTS FIRST, NEARER WINS. Lights and markers have no collider, so the physics
	# ray cannot see them; SceneObjects.pick tests each object's bounds instead. A
	# model's own collision IS hit by the ray, at (about) the same distance its bounds
	# are entered — which is why the object wins a tie.
	var opick := _pick_object(at)
	# A CURVE'S WIRE (§16.7 Q4), by the same rule: a curve lies ON a surface (its points
	# lifted ~0.1 m), so the ray nearly always hits the brush just behind it — nearer wins,
	# never "only where nothing else is hit".
	var cpick := pick_curve(at)
	if not cpick.is_empty():
		var hd0: float = INF
		if not hit.is_empty():
			hd0 = (hit["position"] as Vector3).distance_to(_crosshair_ray(at)["from"] as Vector3)
		if float(cpick["dist"]) <= hd0 + 0.05 and (opick.is_empty() or float(cpick["dist"]) < float(opick["dist"])):
			select_curve(String(cpick["dot"]), add)        # Shift adds (§17.10)
			return
	if not opick.is_empty():
		var hd: float = INF
		if not hit.is_empty():
			var ray := _crosshair_ray(at)
			hd = (hit["position"] as Vector3).distance_to(ray["from"] as Vector3)
		if float(opick["dist"]) <= hd + 0.05:
			select_object(String(opick["dot"]), add)    # Shift-click toggles, like brushes
			return
	if hit.is_empty():
		print("[MapDCLevel] pick: nothing under the crosshair")
		return
	if not level.has_method("pick_face"):
		push_warning("[MapDCLevel] this dc-terrain build predates pick_face — rebuild the extension")
		return
	var brush := _brush_from_hit(hit)
	if brush < 0:
		print("[MapDCLevel] pick: hit %s, which is not level geometry" % str(hit.get("collider")))
		return
	# A PARAMETRIC SHAPE'S PIECE: brush mode selects the SHAPE. Vertex / edge / face mode
	# select the PART like on any brush (#109, user 2026-10-13: "select, prompt on edit";
	# every new box is a shape, so element selection looked broken). The pieces are still
	# read-only (docs/parametric-shapes-design.md §7): an EDIT of one asks first
	# (guard_shape_part: Make Real, then go on / Cancel).
	# A PLACED ASSET's piece (#120 L4): brush mode selects the PLACEMENT, as for a shape.
	var pa := placement_of_brush(brush)
	if pa != "" and _sel_mode == "brush":
		select_asset(pa)
		_sel_point = hit["position"]
		_sel_kind = "brush"
		_update_face_highlight()
		_update_element_highlight()
		_update_op_hud()
		return
	var sd := shape_of_brush(brush)
	if sd != "" and _sel_mode == "brush":
		select_shape(sd)
		_sel_point = hit["position"]
		_sel_kind = "brush"
		_update_face_highlight()
		_update_element_highlight()
		_update_op_hud()
		return
	# BRUSH MODE selects the solid, not a piece of it: every live plane, so the
	# highlight shows the whole thing, with the kind naming what you actually chose.
	# G then moves it bodily instead of dragging one of its planes.
	if _sel_mode == "brush":
		_note_selected(brush, add)
		_sel_faces.clear()
		_sel_edges.clear()
		_sel_keys = level.get_brush(brush).get("plane_keys", PackedStringArray())
		_sel_point = hit["position"]
		_sel_kind = "brush"
		_update_face_highlight()
		_update_element_highlight()
		_update_op_hud()
		print("[MapDCLevel] selected brush %d (%d planes)" % [brush, _sel_keys.size()])
		return

	var face: int = level.pick_face(brush, hit["position"])
	if face < 0:
		print("[MapDCLevel] pick: brush %d has no face at %s" % [brush, str(hit["position"])])
		return

	var p: Vector3 = hit["position"]
	var pts: PackedVector3Array = level.get_face_winding(brush, face)
	var target := p
	var kind := _sel_mode

	# THE MODE DECIDES, and there is no radius any more. Proximity picking could not
	# be ASKED for a face while you stood near its corner; in vertex mode you get the
	# nearest vertex however far it is, because that is what you said you wanted.
	if _sel_mode == "vertex":
		var best := INF
		for v: Vector3 in pts:
			var dv: float = v.distance_to(p)
			if dv < best:
				best = dv
				target = v
	elif _sel_mode == "edge":
		var beste := INF
		for i in range(pts.size()):
			var a: Vector3 = pts[i]
			var b: Vector3 = pts[(i + 1) % pts.size()]
			var ab := b - a
			var t: float = clampf((p - a).dot(ab) / maxf(ab.length_squared(), 1e-9), 0.0, 1.0)
			var q: Vector3 = a + ab * t
			var dq: float = q.distance_to(p)
			if dq < beste:
				beste = dq
				target = q
				# THE ENDPOINTS, captured HERE. _update_element_highlight draws the
				# edge from these two, and the mode rewrite dropped the capture — so
				# edge mode drew a line from the origin to the origin, which is to say
				# nothing at all. They are computed in this loop anyway; recovering
				# them afterwards would mean intersecting the two incident planes and
				# re-clipping against the brush to find where the edge stops.
				_sel_edge_a = a
				_sel_edge_b = b
	if pts.size() < 3:
		# No winding to resolve against — fall back to the face itself rather than
		# selecting a point that came from nowhere.
		target = p
		kind = "face"

	# The incident planes ARE the selection. eps is generous relative to the grab
	# radius so a corner reliably collects all three of its planes.
	var eps: float = maxf(_grid_step() * 0.05, 1e-4)
	var idx: PackedInt32Array = _planes_through(brush, target, eps)
	if idx.is_empty():
		idx = PackedInt32Array([face])
		target = p
		kind = "face"

	var keys: PackedStringArray = level.get_brush(brush).get("plane_keys", PackedStringArray())
	_sel_keys = PackedStringArray()
	for i in idx:
		if i < keys.size():
			_sel_keys.append(keys[i])
	# The cross-brush FACE SET: Shift toggles this face in it, a plain click restarts
	# it. Captured before _note_selected, whose `add` toggle may drop the brush from
	# _sel_brushes when a second face of an already-selected brush is clicked.
	# The cross-brush EDGE SET (§14.4), the same way: Shift toggles, a plain click restarts.
	if kind == "edge":
		var ek := _edge_key(brush, _sel_edge_a, _sel_edge_b)
		var ei := _sel_edges.find(ek)
		if ek.is_empty():
			pass
		elif not add:
			_sel_edges = [ek]
		elif ei >= 0:
			_sel_edges.remove_at(ei)
		else:
			_sel_edges.append(ek)
	else:
		_sel_edges.clear()
	if kind == "face" and face < keys.size():
		var fk := {"b": String(level.get_brush_key(brush)), "p": keys[face]}
		var fi := _sel_faces.find(fk)
		if not add:
			_sel_faces = [fk]
		elif fi >= 0:
			_sel_faces.remove_at(fi)
		else:
			_sel_faces.append(fk)
	else:
		_sel_faces.clear()
	if (kind == "face" or kind == "edge") and add and _sel_brushes.has(brush):
		_sel_brush = brush                # another face / edge of a selected brush: keep it, make it active
	else:
		_note_selected(brush, add)
	_sel_point = target
	_sel_kind = kind
	_probe("pick: wash")
	_update_face_highlight()
	_probe("pick: element")
	_update_element_highlight()
	_probe("pick: handles")
	_sync_handle_visibility()
	_probe("pick: hud")
	_update_op_hud()
	_probe("pick: done")
	print("[MapDCLevel] selected %s on brush %d — %d incident plane(s) %s at %s"
		% [kind, brush, _sel_keys.size(), str(_sel_keys), str(target)])


## Move the selected element by one grid step.
##
## ONE RULE FOR ALL THREE: shift the target point along the average of the
## incident normals, then set d_i = n_i . P' for every incident plane. For a face
## that average IS the face normal, so this reduces exactly to the old nudge
## (d = n.(P + n*step) = d + step) — vertex and edge are not a special case bolted
## on, they are the same operation at k=0 and k=1.
##
## Every plane move is journalled separately, carrying the plane it replaced,
## because set_face_plane has no other inverse.
func _nudge_element(delta: float) -> void:
	if _sel_kind == "brush":
		# Every plane pushed outward is a SCALE, not a move, and nobody pressing `]`
		# on a selected brush means that. G moves it.
		print("[MapDCLevel] brush mode: press G to move the brush")
		return
	# A shape's part: Make Real first (or Cancel), then this nudge on the same part.
	if not guard_shape_part("nudge", _nudge_element.bind(delta)):
		return
	var dir := element_out_dir()
	if dir == Vector3.ZERO:
		return                          # element_out_dir has already said why
	# Proportional (O): the nearby corners, collected BEFORE the part moves, follow after.
	var near: Array = prop_corners(element_corners(), _sel_brush)
	var from := _sel_point
	_ensure_persistence()
	_persist.begin_gesture("Nudge")           # the part and its followers: one Ctrl+Z
	if apply_element_move(_sel_point + dir * delta) > 0 and not near.is_empty():
		prop_move_corners(near, _sel_point - from)
	_persist.end_gesture()


## The selected element's incident planes, as CURRENT indices. Empty if there is no
## selection or every plane of it has gone — both of which are said out loud, because
## a nudge that silently does nothing is indistinguishable from a broken key.
##
## Extracted so the drag operator and the `[` / `]` nudge resolve the selection ONE
## way. Two implementations of "which planes am I moving" is the shape of bug this
## codebase keeps paying for.
func element_planes() -> PackedInt32Array:
	if _sel_brush < 0 or _sel_keys.is_empty():
		print("[MapDCLevel] nothing selected — aim at a face, edge or corner and press B")
		return PackedInt32Array()
	if level.get_brush(_sel_brush).is_empty():
		print("[MapDCLevel] selected brush %d is gone" % _sel_brush)
		_clear_face_selection()
		return PackedInt32Array()
	# Resolve dots to current indices; a plane that vanished drops out.
	var idx := PackedInt32Array()
	for k: String in _sel_keys:
		var i: int = level.face_by_key(_sel_brush, k)
		if i >= 0:
			idx.append(i)
	if idx.is_empty():
		print("[MapDCLevel] every selected plane is gone from brush %d" % _sel_brush)
		_clear_face_selection()
		return idx
	return idx


## Which way "out" is for the selection: the average of its incident normals. For a
## face that IS the face normal; for a corner it is the diagonal you would expect to
## drag it along. ZERO when opposed normals cancel, which is a real shape (a slab
## selected on both sides) and not an error.
func element_out_dir() -> Vector3:
	var idx: PackedInt32Array = element_planes()
	if idx.is_empty():
		return Vector3.ZERO
	var pl: PackedFloat32Array = level.get_brush(_sel_brush).get("planes", PackedFloat32Array())
	var dir := Vector3.ZERO
	for i in idx:
		dir += Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
	if dir.length() < 1e-6:
		print("[MapDCLevel] those planes have no common outward direction")
		return Vector3.ZERO
	return dir.normalized()


## CSG — the ACTIVE brush against the others in the selection.
##
## Blender's model, which is what the selection set was built for: the last brush you
## clicked is the one that survives and is operated ON, and everything else selected is
## an operand. TrenchBroom has no active concept — its csgSubtract takes the selection
## as CUTTERS and auto-selects everything touching them as targets
## (Map_Geometry.cpp:685-700). That is fewer clicks and alarming here, where a cutter
## grazing a floor and three walls would carve all four unasked.
##
## THE OPERANDS ARE CONSUMED BY DEFAULT — `csg_discard`, Alt+H toggles it. A cutter
## is usually a tool shape, not level geometry, and leaving it behind means deleting it
## by hand every time (TrenchBroom deletes its subtrahends for the same reason). But
## not always — one cutter can open a row of doorways (user 2026-10-05: "there are
## times when you do and do not want to discard"). KEPT, the cutters come back as the
## selection: still solid, standing in the hole they cut, so G moves one on to the next
## cut. Merge consumes its operands either way — it is one hull of all of them.
## Undo brings everything back, because every step here is a record and undo is a
## re-fold.
func _csg(op: String) -> void:
	if _sel_brush < 0 or _sel_brushes.size() < 2:
		print("[csg] %s needs at least two brushes selected — the ACTIVE one is the "
			% op + "target, the rest are operands")
		return
	var operands: Array[int] = []
	for b: int in _sel_brushes:
		if b != _sel_brush:
			operands.append(b)
	if op != "merge" and _live_cut(op, operands):
		return
	if op == "slice":
		# LIVE ONLY (§9c): there is no destructive slice, and doing a subtract instead
		# would be doing something else without saying so.
		print("[csg] Slice needs a SHAPE as the cutter, and the target in a shape or group")
		return

	match op:
		"merge":
			# One call: the hull of every selected brush's vertices, active first so
			# it is the survivor. NOT a union — it fills the space between separated
			# brushes — which is why the binding says "convex merge" and always will.
			var ids := PackedInt32Array([_sel_brush])
			var keys: Array = [level.get_brush_key(_sel_brush)]
			for b: int in operands:
				ids.append(b)
				keys.append(level.get_brush_key(b))
			var dot: String = _persist.reserve_dot()
			if not level.merge_brushes(ids, dot):
				print("[csg] merge refused — that hull encloses nothing")
				return
			_persist.record_at(dot, "brush.merge", {"brush_keys": keys})
			print("[csg] convex merge of %d brushes (a HULL, not a union)" % ids.size())

		"intersect":
			# Each operand narrows the active brush further. One record per operand,
			# each carrying that operand's PLANE SET — never its id, which means
			# something different on every peer.
			for b: int in operands:
				var pl: PackedFloat32Array = level.get_brush(b).get("planes", PackedFloat32Array())
				var dot2: String = _persist.reserve_dot()
				if not level.intersect_brush(_sel_brush, pl, dot2):
					print("[csg] intersect refused — those brushes do not overlap")
					return
				var flat: Array = []
				for f: float in pl:
					flat.append(f)
				_persist.record_at(dot2, "brush.intersect", {
					"brush_key": level.get_brush_key(_sel_brush), "planes": flat,
				})
			if csg_discard:
				_remove_brushes(operands)
			print("[csg] intersected with %d brush(es)" % operands.size())

		"subtract":
			# THE PIECES ARE THEMSELVES TARGETS. Subtracting the second cutter has to
			# happen against everything the first one left, so this accumulates — and
			# that is why three cutters against one wall can leave nine brushes rather
			# than four. TrenchBroom folds subtrahends over minuends the same way and
			# multiplies the same. Said out loud below, because discovering it by
			# watching a wall become nine brushes is worse than being told.
			var targets: Array[int] = [_sel_brush]
			for b: int in operands:
				var cut: PackedFloat32Array = level.get_brush(b).get("planes", PackedFloat32Array())
				var flat2: Array = []
				for f2: float in cut:
					flat2.append(f2)
				var next: Array[int] = []
				for t: int in targets:
					var dot3: String = _persist.reserve_dot()
					var tkey: String = level.get_brush_key(t)
					var n: int = level.subtract_brush(t, cut, dot3)
					if n < 0:
						next.append(t)      # this cutter missed this piece: keep it
						continue
					_persist.record_at(dot3, "brush.subtract", {
						"brush_key": tkey, "planes": flat2,
					})
					for k in range(n):
						var pid: int = level.brush_by_key(dot3 if k == 0 else "%s:%d" % [dot3, k])
						if k == 0:
							pid = t          # the first piece keeps the target's id
						if pid > 0:
							next.append(pid)
				targets = next
			if csg_discard:
				_remove_brushes(operands)
			print("[csg] subtract left %d piece(s) from %d cutter(s)%s"
				% [targets.size(), operands.size(), "" if csg_discard else " (cutters kept)"])

	level.rebuild()
	_refresh_brush_view()
	# THE SELECTION IS STALE. Brushes were consumed and faces re-derived, so the dots
	# it holds may name geometry that is gone — and a selection quietly pointing at
	# the wrong brush is how the NEXT csg op eats something nobody chose.
	_clear_face_selection()
	# Kept cutters are untouched by the op, so their ids are still good: hand them
	# back as the selection, ready to move on to the next cut. select_brushes, not
	# _note_selected: it sets the "brush" kind and grab point G needs.
	if not csg_discard and op != "merge":
		select_brushes(PackedInt32Array(operands))


## LIVE CUTS — live by default (user 2026-10-12; docs/cutting-and-operations.md §9a).
## The active brush's GROUP (a shape's dot, an operator group) is the target, and every
## operand must be a piece of a parametric shape: that shape becomes the cutter. One
## op.create per cutter shape, one gesture. Moving or editing either re-cuts; Make Real
## on the target bakes it. False (and nothing written) when the selection is not that —
## _csg then cuts destructively, as it always has.
func _live_cut(op: String, operands: Array[int]) -> bool:
	if level == null or not level.has_method("get_cutter_brush_ids"):
		return false                    # a DCLevel build without brush.cut
	var target := String(level.get_brush_group(_sel_brush))
	if target == "" or operands.is_empty():
		return false
	var cutters := {}
	for b: int in operands:
		var d := shape_of_brush(b)
		if d == "" or d == target:
			return false
		cutters[d] = true
	_ensure_persistence()
	_persist.begin_gesture("Live %s" % op)
	var n := 0
	for d: String in cutters:
		if _persist.record("op.create", {"op": op, "cutter": d, "targets": [target]}) != "":
			n += 1
	_persist.end_gesture()
	_persist.refold()
	_refresh_brush_view()
	_clear_face_selection()
	var ids := PackedInt32Array()
	for d: String in cutters:
		ids.append_array(level.get_group_brush_ids(d))
	select_brushes(ids)
	print("[csg] LIVE %s: %d cutter shape(s) on %s — move or edit either and it re-cuts; Make Real on the target bakes it"
		% [op, n, target])
	return n > 0


## Make Real on a cut's TARGET bakes the cut in, so the ops cutting only it stop
## (op.apply) and a cutter no other live op uses goes with them (shape.remove) —
## Blender's Apply. Called inside the caller's gesture: one Ctrl+Z brings it all back.
func _retire_ops_on(target: String) -> void:
	# EXISTING ops, off ones too: an op switched off in the Stack panel still belongs to
	# the stack, and baking the target ends it either way.
	var ops: Array = LevelPersistence.OpFold.existing_ops(_persist.records(false))
	var retired := {}
	for s: Dictionary in ops:
		var tg: PackedStringArray = s["targets"]
		if tg.size() == 1 and tg[0] == target:
			_persist.record("op.apply", {"target": s["dot"]})
			retired[s["dot"]] = true
	var removed := {}
	for s2: Dictionary in ops:
		var c := String(s2["cutter"])
		if not retired.has(s2["dot"]) or c == "" or removed.has(c):
			continue
		var still_used := false
		for s3: Dictionary in ops:
			if not retired.has(s3["dot"]) and String(s3["cutter"]) == c:
				still_used = true
		if not still_used:
			_persist.record("shape.remove", {"target": c})
			removed[c] = true


## ---- THE STACK PANEL's API (docs/cutting-and-operations.md §9b) ----
##
## SUBJECT = the selected shape, else the active brush's group ("" = nothing stackable).
func stack_subject() -> String:
	if _sel_shape != "":
		return _sel_shape
	if _sel_brush >= 0 and level != null:
		var d := shape_of_brush(_sel_brush)
		if d != "":
			return d
		return String(level.get_brush_group(_sel_brush))
	return ""


## The subject's ops, in stack order: [{dot, kind, on, cutter, depth}] (depth: inset / outset).
func op_stack(subject: String) -> Array:
	_ensure_persistence()
	var out: Array = []
	for s: Dictionary in LevelPersistence.OpFold.stack_of(_persist.records(false), subject):
		out.append({"dot": s["dot"], "kind": s["kind"], "on": bool(s.get("on", true)), "cutter": s["cutter"],
			"depth": float(s.get("depth", 0.25))})
	return out


## The ops cutting WITH `cutter`: [{dot, kind, on, target}] (one per target).
func cuts_by(cutter: String) -> Array:
	_ensure_persistence()
	var out: Array = []
	for s: Dictionary in LevelPersistence.OpFold.cuts_by(_persist.records(false), cutter):
		for t: String in (s["targets"] as PackedStringArray):
			out.append({"dot": s["dot"], "kind": s["kind"], "on": bool(s.get("on", true)), "target": t})
	return out


## One op.set (the eye: {on}; the kind: {op}) — one gesture, one Ctrl+Z.
func set_op(dot: String, fields: Dictionary) -> bool:
	_ensure_persistence()
	var p := fields.duplicate()
	p["target"] = dot
	var label := "Cut on" if bool(fields.get("on", true)) else "Cut off"
	if fields.has("op"):
		label = "Cut: %s" % String(fields["op"])
	_persist.begin_gesture(label)
	var ok: bool = _persist.record("op.set", p) != ""
	_persist.end_gesture()
	if ok:
		_persist.refold()
		_refresh_brush_view()
	return ok


## ✕ — op.remove. Its cutter becomes a solid shape again (it cuts nothing now); Ctrl+Z
## brings the cut back.
func remove_op(dot: String) -> bool:
	_ensure_persistence()
	_persist.begin_gesture("Remove cut")
	var ok: bool = _persist.record("op.remove", {"target": dot}) != ""
	_persist.end_gesture()
	if ok:
		_persist.refold()
		_refresh_brush_view()
	return ok


## Tombstone the operands, one record each, so undo brings them back.
func _remove_brushes(ids: Array[int]) -> void:
	for b: int in ids:
		var bkey: String = level.get_brush_key(b)
		if level.remove_brush(b):
			_persist.record("brush.remove", {"brush_key": bkey})


## THE CORNERS a vertex or edge drag actually moves, or empty for a face.
##
## Empty is the signal to use the exact PLANE path instead: a face IS one plane, so
## moving it is one number and there is nothing to rebuild. A vertex and an edge have
## no such luxury — they are the meet of three planes and of two, so moving them by
## moving planes drags every face those planes carry, which is what "vertex mode
## moves the whole brush" has meant every time it was reported.
func element_drag_points() -> PackedVector3Array:
	match _sel_kind:
		"vertex":
			return PackedVector3Array([_sel_point])
		"edge":
			return PackedVector3Array([_sel_edge_a, _sel_edge_b])
	return PackedVector3Array()


## ---- LOCAL AXES (#109, user 2026-10-13: "double tap of an axis to switch to normal
## aligned — I rotate a cube and K still cuts on the global axis") -------------------
##
## Blender's X then X again: the second press is the thing's OWN axis. A parametric
## shape has a frame, and that is its local basis. A plain brush has only planes, so its
## local X / Y / Z are the face normals most aligned with the world's X / Y / Z,
## orthonormalised in that order — a rotated cube's own face normals, and the world
## basis for anything axis-aligned. LEVEL space.
func brush_local_basis(b: int) -> Basis:
	var sd := shape_of_brush(b)
	if sd != "":
		var st: Dictionary = _persist.shape_state(sd)
		if st.get("frame") is Transform3D:
			return (st["frame"] as Transform3D).basis.orthonormalized()
	var pl: PackedFloat32Array = level.get_brush(b).get("planes", PackedFloat32Array())
	var picked: Array[Vector3] = []
	for e: Vector3 in [Vector3.RIGHT, Vector3.UP, Vector3.BACK]:
		var best := Vector3.ZERO
		for i in pl.size() / 4:
			var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).normalized()
			if absf(n.dot(e)) > absf(best.dot(e)):
				best = n
		picked.append(best if best.dot(e) >= 0.0 else -best)
	var x: Vector3 = picked[0]
	var y: Vector3 = (picked[1] - x * picked[1].dot(x))
	if x.length() < 1e-6 or y.length() < 1e-6:
		return Basis.IDENTITY
	y = y.normalized()
	return Basis(x.normalized(), y, x.normalized().cross(y))


## ---- ELEMENT ROTATE (#109, user 2026-10-13: "rotate with 3d cursor as pivot doesn't
## work for edge / vertex, it rotates the entire brush") --------------------------------
##
## RotateOperator only ever turned whole brushes. The element rule is element MOVE's:
## A FACE TURNS ITS PLANE (one face.set, rigid about the pivot; neighbours stretch, as
## for extrude), A CORNER MOVES ITSELF (brush.reshape: re-hull with the named corners
## moved). An edge is two corners going to two different places, and move_brush_points
## takes one delta — so one reshape per endpoint, both applied locally BEFORE either is
## journalled; if the store refuses either, refold() puts the level back exactly as the
## journal says. The operator stack wraps the confirm in one gesture: one Ctrl+Z.

## Where an element turns about: the 3D cursor in cursor mode, else the element's own
## centre — a vertex, an edge's midpoint, a face's polygon centroid. LEVEL space.
func element_pivot() -> Vector3:
	if pivot_mode == "cursor" and _cursor3d != null:
		return (level as Node3D).global_transform.affine_inverse() * _cursor3d.global_position
	match _sel_kind:
		"edge":
			return (_sel_edge_a + _sel_edge_b) * 0.5
		"face":
			var idx: PackedInt32Array = element_planes()
			if not idx.is_empty():
				var w: PackedVector3Array = level.get_face_winding(_sel_brush, idx[0])
				if not w.is_empty():
					var c := Vector3.ZERO
					for p: Vector3 in w:
						c += p
					return c / float(w.size())
	return _sel_point


func apply_element_rotate(axis: Vector3, deg: float, pivot: Vector3) -> int:
	var idx: PackedInt32Array = element_planes()
	if idx.is_empty() or is_zero_approx(fmod(deg, 360.0)):
		return 0
	var r := Basis(axis.normalized(), deg_to_rad(deg))
	if _sel_kind == "face":
		var pl: PackedFloat32Array = level.get_brush(_sel_brush).get("planes", PackedFloat32Array())
		var i: int = idx[0]
		var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
		var d: float = pl[i * 4 + 3]
		var n2: Vector3 = r * n
		var q2: Vector3 = r * (n * d - pivot) + pivot        # a point on the plane, turned
		if not level.set_face_plane(_sel_brush, i, n2, n2.dot(q2)):
			print("[MapDCLevel] refused: turning that face would leave brush %d enclosing nothing" % _sel_brush)
			return 0
		_sel_point = r * (_sel_point - pivot) + pivot
		level.rebuild()
		_update_face_highlight()
		_update_element_highlight()
		var keys: PackedStringArray = level.get_brush(_sel_brush).get("plane_keys", PackedStringArray())
		if i < keys.size():
			_persist.record("face.set", {"brush_key": level.get_brush_key(_sel_brush), "face_key": keys[i],
				"n": [n2.x, n2.y, n2.z], "d": n2.dot(q2)})
		print("[MapDCLevel] face turned %+.1f° about %s" % [deg, str(pivot)])
		return 1
	var corners: PackedVector3Array = element_drag_points()
	if corners.is_empty():
		return 0
	var bkey: String = level.get_brush_key(_sel_brush)
	var moves: Array = []                                   # [dot, from, delta] per corner
	for c: Vector3 in corners:
		var delta: Vector3 = r * (c - pivot) + pivot - c
		var dot: String = _persist.reserve_dot()
		if not level.move_brush_points(_sel_brush, PackedVector3Array([c]), delta, dot):
			print("[MapDCLevel] refused: turning that %s would not leave a solid — nothing changed" % _sel_kind)
			_persist.refold()                                 # undo the corners already moved
			return 0
		moves.append([dot, c, delta])
	level.rebuild()
	_refresh_brush_view()
	for m: Array in moves:
		var c2: Vector3 = m[1]
		var dl: Vector3 = m[2]
		_persist.record_at(String(m[0]), "brush.reshape", {"brush_key": bkey,
			"points": [[c2.x, c2.y, c2.z]], "delta": [dl.x, dl.y, dl.z]})
	print("[MapDCLevel] %s turned %+.1f° about %s (%d corner(s))" % [_sel_kind, deg, str(pivot), moves.size()])
	# The plane set was re-derived (element move's rule): the dots may name faces
	# that no longer exist, so the selection is dropped rather than left pointing wrong.
	_clear_face_selection()
	return moves.size()


## Move every incident plane so it passes through `target`, keeping its normal.
## Returns how many moved, or 0 if the store refused and nothing changed.
##
## THIS IS THE WHOLE OPERATION, for the keyboard nudge and the drag operator alike.
func apply_element_move(target: Vector3) -> int:
	var idx: PackedInt32Array = element_planes()
	if idx.is_empty():
		return 0
	var pl: PackedFloat32Array = level.get_brush(_sel_brush).get("planes", PackedFloat32Array())

	# Apply as a batch, and ROLL BACK if any plane refuses: a partly-moved vertex
	# is not the shape the player asked for, and the empty-brush guard rejecting
	# one of three planes would otherwise leave a corner torn open.
	var applied := []
	var failed := false
	for i in idx:
		var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
		var prev_d: float = pl[i * 4 + 3]
		if level.set_face_plane(_sel_brush, i, n, n.dot(target)):
			applied.append([i, n, prev_d])
		else:
			failed = true
			break
	if failed:
		for e in applied:
			level.set_face_plane(_sel_brush, e[0], e[1], e[2])
		print("[MapDCLevel] refused: that would leave brush %d enclosing nothing" % _sel_brush)
		return 0

	_sel_point = target
	level.rebuild()
	_update_face_highlight()
	_update_element_highlight()
	if not _persist.is_applying():
		var keys2: PackedStringArray = level.get_brush(_sel_brush).get("plane_keys", PackedStringArray())
		var bkey: String = level.get_brush_key(_sel_brush)
		for e in applied:
			var n2: Vector3 = e[1]
			if e[0] < keys2.size():
				# NO `prev`. The record says where the plane IS; undo is the record
				# going away and the fold recomputing from the ones that remain. A
				# stored inverse is only correct for a linear history of one plane —
				# two nudges and an undo of the first put it where nobody asked.
				_persist.record("face.set", {
					"brush_key": bkey,
					"face_key": keys2[e[0]],
					"n": [n2.x, n2.y, n2.z],
					"d": n2.dot(target),
				})
	print("[MapDCLevel] %s moved %d plane(s) to %s  %s"
		% [_sel_kind, applied.size(), str(target), str(level.get_stats())])
	return applied.size()


## Hide the placeholder once the level actually has geometry.
func drop_placeholder_floor() -> void:
	if placeholder_floor != null:
		placeholder_floor.queue_free()
		placeholder_floor = null
		print("[MapDCLevel] placeholder floor removed — level geometry is authoritative")

# ---- (C) two-point face-to-face fill ----------------------------------------
#
# Cepero's Elastic Fill, which is the only interaction in either prior source
# designed for AIMING rather than adapted to it (hyper-games #21):
#
#   "You need to point to two opposing faces... You point to the start place,
#    hold a key down, and then point to the end place. You see some sort of
#    elastic box showing you where the voxels will appear. Once you release the
#    key, the program will fill the space you have highlighted."
#
# FACE to FACE, not point to point — which is exactly why it lands on this API
# with no adaptation and no virtual cursor: pick_face already turns an aim into a
# face, and get_face_winding already gives that face's polygon to draw.
#
# THE SOLID IS BUILT BY CONSTRUCTION, NOT BY HULLING POINTS. Take the start
# brush's plane set, FLIP the start face's plane so the new solid lies on the far
# side of it, and append the target face's plane flipped as the far cap. An
# intersection of half-spaces is convex by definition, so the result is a valid
# brush with no vertex solving and no plane re-fitting — the property the whole
# level-mesh architecture rests on.
#
# JOURNALLED now. It could not be before: brush ids came from a local
# `next_brush_id_++`, so two peers holding the same map had their counters in the
# same place and each allocated THE SAME id for DIFFERENT geometry — replicating
# that would have been known-unsound, which is why this shipped local-only. Brushes
# are now named by the dot of the op that created them, exactly as faces already
# were, so a fill is a `brush.create` record like any other.

## A mis-aimed second point should not fill a room.
const FILL_MAX_SPAN := 64.0

## Last aimed brush the frame probe reported, so it prints on change rather than
## sixty times a second.
var _probe_last_aim: int = -2

var _fill_from_brush: int = -1
var _fill_from_key: String = ""
var _fill_ghost: MeshInstance3D = null


func _process(_delta: float) -> void:
	# UV keys exist only while something is selected (keymap `face_sel`).
	var want_face_sel := not _sel_brushes.is_empty()
	if want_face_sel != _face_sel_on:
		_face_sel_on = want_face_sel
		if want_face_sel:
			Keymap.push_context("face_sel", self)
		else:
			Keymap.pop_context("face_sel", self)
	# One int compare unless a face texture (or the resolution) actually changed.
	if _packs != null:
		_packs.sync()
	_update_object_outline()
	if _fill_from_brush >= 0:
		_update_fill_ghost()
	# Outline whatever is under the crosshair. One ray, and set_aimed does nothing
	# unless the target actually changed, so this is a comparison per frame in the
	# common case of standing still.
	if get_viewport() != null:
		_update_pointer_wrap_at(get_viewport().get_mouse_position())
	if _ops != null and is_instance_valid(_ops) and _ops.active != null:
		_ops.update_preview()
		# A tool's handle dots over its preview (§17.9).
		var hpts: PackedVector3Array = (_ops.active.modal() as ModalControls).handle_points()
		if not hpts.is_empty():
			_ops.active.draw_points(hpts)
		_update_op_hud()
	# Face handles only with no tool up (the tool draws its own preview).
	if _handle_view != null and is_instance_valid(_handle_view):
		_handle_view.visible = GameMode.is_building() and (_ops == null or not is_instance_valid(_ops) or _ops.active == null)
	# ONE crosshair ray per frame, shared by the grid and the brush-view aim. None while
	# the free pointer is over an editor area (#124): hover lit up what was BEHIND a panel.
	# The VIRTUAL pointer, as _crosshair_ray reads it (mouse + the wrap offset).
	var over_area: bool = GameMode.wants_pointer() and BuildDock != null \
		and BuildDock.covers(_pointer_pos(get_viewport().get_mouse_position()) + _wrap_offset)
	var frame_hit: Dictionary = _crosshair_hit() if GameMode.is_building() and not over_area else {}
	_update_editor_grid(frame_hit)
	if _brush_view != null and is_instance_valid(_brush_view) and _brush_view.visible:
		# Per FRAME, and the only editor path that runs without any key at all — so a
		# crash "on C" could equally be this, one frame later. Probed only when the
		# aimed brush CHANGES, or it would be one line per frame forever.
		var aim_now: int = _brush_from_hit(frame_hit)
		if aim_now != _probe_last_aim:
			_probe_last_aim = aim_now
			_probe("frame: set_aimed %d" % aim_now)
		_brush_view.set_aimed(aim_now)


func _fill_begin() -> void:
	var hit := _crosshair_hit()
	if hit.is_empty():
		print("[MapDCLevel] fill: nothing under the crosshair")
		return
	var brush := _brush_from_hit(hit)
	if brush < 0:
		print("[MapDCLevel] fill: that is not level geometry")
		return
	var face: int = level.pick_face(brush, hit["position"])
	if face < 0:
		return
	var keys: PackedStringArray = level.get_brush(brush).get("plane_keys", PackedStringArray())
	if face >= keys.size():
		return
	# Held by DOT, not index: the start face must survive whatever happens between
	# press and release.
	_fill_from_brush = brush
	_fill_from_key = keys[face]
	print("[MapDCLevel] fill: from brush %d face %d (%s) — aim at the opposing face and release"
		% [brush, face, _fill_from_key])


func _fill_commit() -> void:
	var from_brush := _fill_from_brush
	var from_key := _fill_from_key
	_fill_from_brush = -1
	_fill_from_key = ""
	_clear_fill_ghost()
	if from_brush < 0:
		return

	var hit := _crosshair_hit()
	if hit.is_empty():
		print("[MapDCLevel] fill: released on nothing — cancelled")
		return
	var to_brush := _brush_from_hit(hit)
	if to_brush < 0:
		print("[MapDCLevel] fill: released on non-level geometry — cancelled")
		return
	var to_face: int = level.pick_face(to_brush, hit["position"])
	var from_face: int = level.face_by_key(from_brush, from_key)
	if to_face < 0 or from_face < 0:
		print("[MapDCLevel] fill: a face went away between press and release — cancelled")
		return

	var fb: Dictionary = level.get_brush(from_brush)
	var tb: Dictionary = level.get_brush(to_brush)
	var fp: PackedFloat32Array = fb.get("planes", PackedFloat32Array())
	var fproj: PackedInt32Array = fb.get("proj", PackedInt32Array())
	var tp: PackedFloat32Array = tb.get("planes", PackedFloat32Array())

	var sn := Vector3(fp[from_face * 4], fp[from_face * 4 + 1], fp[from_face * 4 + 2])
	var sd: float = fp[from_face * 4 + 3]
	var tn := Vector3(tp[to_face * 4], tp[to_face * 4 + 1], tp[to_face * 4 + 2])
	var td: float = tp[to_face * 4 + 3]

	# OPPOSING is the precondition Cepero states, and it is geometric, not
	# stylistic: if the two outward normals do not face each other there is no
	# bounded region between them and the "fill" is an unbounded slab.
	var facing: float = sn.dot(tn)
	if facing > -0.1:
		print("[MapDCLevel] fill: those faces do not oppose each other (n·n = %.2f)" % facing)
		return

	var span: float = absf(sd + td)   # distance between the two planes along sn
	if span > FILL_MAX_SPAN:
		print("[MapDCLevel] fill: %.1f m span exceeds the %.1f m limit" % [span, FILL_MAX_SPAN])
		return

	# Start brush's walls, its start face flipped, plus the target plane as a cap.
	var planes := PackedFloat32Array()
	var proj := PackedInt32Array()
	for i in range(fp.size() / 4):
		var n := Vector3(fp[i * 4], fp[i * 4 + 1], fp[i * 4 + 2])
		var d: float = fp[i * 4 + 3]
		if i == from_face:
			n = -n
			d = -d
		planes.append_array(PackedFloat32Array([n.x, n.y, n.z, d]))
		proj.append(fproj[i] if i < fproj.size() else 0)
	planes.append_array(PackedFloat32Array([-tn.x, -tn.y, -tn.z, -td]))
	proj.append(0)

	# TWO-PHASE, because a create needs its durable name BEFORE it can be applied —
	# the name is what the brush is called — while the store may still refuse the
	# plane set. Reserve the dot, try it, and only write the record if it took: a
	# record for a brush that does not exist would be replayed by every peer forever.
	var dot: String = _persist.reserve_dot()
	# Textured at birth like the box (#94 P1 §6): the current texture on every plane.
	var face_tex: Array = []
	var cur_tex := current_face_tex()
	for _p in planes.size() / 4:
		face_tex.append(cur_tex.duplicate())
	var nid: int = level.add_brush(planes, int(fb.get("material_id", 0)), proj,
								   int(fb.get("level_id", 0)), false, dot, false, "", face_tex)
	if nid <= 0:
		# add_brush now validates emptiness itself, the way set_face_plane and
		# add_face always did — so an impossible span is refused at creation instead
		# of being created, rebuilt, counted and tombstoned after the fact.
		print("[MapDCLevel] fill: refused — that plane set encloses nothing")
		return
	level.rebuild()

	var live: int = int(level.get_brush(nid).get("live_faces", 0))
	if live < 4:
		# Encloses SOMETHING but not a solid worth having. Tombstone locally and
		# write no record.
		level.remove_brush(nid)
		level.rebuild()
		print("[MapDCLevel] fill: refused — only %d live faces" % live)
		return

	# JOURNALLED, which it could not be before: brush ids came from a local counter,
	# so two peers creating a brush allocated the same id for different geometry and
	# replicating that was known-unsound. The dot fixes exactly that.
	var plane_list: Array = []
	for f: float in planes:
		plane_list.append(f)
	var proj_list: Array = []
	for p: int in proj:
		proj_list.append(p)
	_persist.record_at(dot, "brush.create", {
		"planes": plane_list,
		"proj": proj_list,
		"material_id": int(fb.get("material_id", 0)),
		"level_id": int(fb.get("level_id", 0)),
		"sky": false,
		"face_tex": face_tex,
	})
	print("[MapDCLevel] fill: brush %s (local %d), %d live faces, %.2f m span  %s"
		% [dot, nid, live, span, str(level.get_stats())])


## Both faces, drawn while the key is held. Not the resulting solid: showing that
## would mean building it every frame, and speculative mutation is exactly what
## poisons `generation` for anything downstream that compares it.
func _update_fill_ghost() -> void:
	if _fill_ghost == null or not is_instance_valid(_fill_ghost):
		_fill_ghost = MeshInstance3D.new()
		_fill_ghost.name = "FillGhost"
		_fill_ghost.mesh = ImmediateMesh.new()
		var m := StandardMaterial3D.new()
		m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
		m.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
		m.albedo_color = Color(1.0, 0.7, 0.2, 0.35)
		m.cull_mode = BaseMaterial3D.CULL_DISABLED
		m.no_depth_test = true
		_fill_ghost.material_override = m
		_fill_ghost.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF
		level.add_child(_fill_ghost)
	var im: ImmediateMesh = _fill_ghost.mesh
	im.clear_surfaces()

	var from_face: int = level.face_by_key(_fill_from_brush, _fill_from_key)
	if from_face < 0:
		return
	var polys: Array[PackedVector3Array] = []
	polys.append(level.get_face_winding(_fill_from_brush, from_face))
	var hit := _crosshair_hit()
	if not hit.is_empty():
		var tb := _brush_from_hit(hit)
		if tb >= 0:
			var tf: int = level.pick_face(tb, hit["position"])
			if tf >= 0:
				polys.append(level.get_face_winding(tb, tf))

	im.surface_begin(Mesh.PRIMITIVE_TRIANGLES)
	for pts: PackedVector3Array in polys:
		# Convex by construction — a brush face is an intersection of half-spaces —
		# so a fan is exact.
		for i in range(1, pts.size() - 1):
			im.surface_add_vertex(pts[0])
			im.surface_add_vertex(pts[i])
			im.surface_add_vertex(pts[i + 1])
	im.surface_end()


func _clear_fill_ghost() -> void:
	if _fill_ghost != null and is_instance_valid(_fill_ghost):
		(_fill_ghost.mesh as ImmediateMesh).clear_surfaces()


## ---- NEW BRUSHES FROM SELECTED FACES (user 2026-10-05) ------------------------
##
## "select multiple faces of close brushes to create new brushes that we can use for
## csg". Face mode, Shift-click across brushes builds `_sel_faces`; then:
##   Alt+J  HULL     one brush, the convex hull of every selected face's corners. Two
##                   facing walls give the block between them; faces of one brush a
##                   slab shaped like them. Same hull as convex merge (J), by
##                   DCLevel.hull_planes — one hull rule, not two.
##   Alt+E  EXTRUDE  one brush PER face: the face pushed out one grid step.
## The sources are untouched. The new brushes come back SELECTED in brush mode, the
## shape CSG wants next (Shift-click a wall to make it the active target, then H).
## Each is a brush.create — the same record fill and the box write.

## Face windings of `_sel_faces`, resolved: [{brush, face, pts, n}].
func _selected_face_polys() -> Array:
	var out: Array = []
	# A removed brush is a TOMBSTONE that brush_by_key still returns: only LIVE brushes'
	# faces count (selected_edges' rule, gate_edge_set E8; gate_face_set_live).
	var live := {}
	for id: int in (level.get_live_brush_ids() as PackedInt32Array):
		live[id] = true
	for f: Dictionary in _sel_faces:
		var b: int = level.brush_by_key(String(f["b"]))
		if b <= 0 or not live.has(b):
			continue
		var fi: int = level.face_by_key(b, String(f["p"]))
		if fi < 0:
			continue
		var pts: PackedVector3Array = level.get_face_winding(b, fi)
		if pts.size() < 3:
			continue
		var pl: PackedFloat32Array = level.get_brush(b).get("planes", PackedFloat32Array())
		var n := Vector3(pl[fi * 4], pl[fi * 4 + 1], pl[fi * 4 + 2]).normalized()
		out.append({"brush": b, "face": fi, "pts": pts, "n": n})
	return out


## Add one brush and journal it: add_brush under a reserved dot, and the brush.create
## record only if the store took it (fill's two-phase rule). -1 if refused.
func _create_brush_journalled(p: Dictionary) -> int:
	var dot: String = _persist.reserve_dot()
	var nid: int = level.add_brush(PackedFloat32Array(p["planes"]), int(p.get("material_id", 0)),
		PackedInt32Array(p.get("proj", [])), int(p.get("level_id", 0)), bool(p.get("sky", false)),
		dot, bool(p.get("clearance", false)), String(p.get("group", "")), p.get("face_tex", []))
	if nid <= 0:
		return -1
	_persist.record_at(dot, "brush.create", p)
	return nid


## A brush.create payload for a NEW brush bounded by `planes`, textured at birth with
## the current texture (like the box and fill), material/level from `like`.
func _new_brush_payload(planes: PackedFloat32Array, like: int) -> Dictionary:
	var lb: Dictionary = level.get_brush(like)
	var pl: Array = []
	var proj: Array = []
	var ft: Array = []
	var cur := current_face_tex()
	for f: float in planes:
		pl.append(f)
	for _i in planes.size() / 4:
		proj.append(0)
		ft.append(cur.duplicate())
	return {"planes": pl, "proj": proj, "material_id": int(lb.get("material_id", 0)),
		"level_id": int(lb.get("level_id", 0)), "sky": false, "face_tex": ft}


## Hull of the selected faces' corners as one new brush. Its id, or -1.
func faces_to_hull() -> int:
	var polys := _selected_face_polys()
	if polys.size() < 1:
		print("[faces] hull: select faces first (face mode, Shift-click to add)")
		return -1
	var pts := PackedVector3Array()
	for p: Dictionary in polys:
		pts.append_array(p["pts"])
	var planes: PackedFloat32Array = level.hull_planes(pts)
	if planes.is_empty():
		print("[faces] hull: those faces all lie in one plane — nothing to fill (Alt+E extrudes a face)")
		return -1
	_persist.begin_gesture("Brush from faces")
	var nid := _create_brush_journalled(_new_brush_payload(planes, int(polys[0]["brush"])))
	_persist.end_gesture()
	if nid < 0:
		print("[faces] hull: the store refused that hull")
		return -1
	_after_new_brushes(PackedInt32Array([nid]))
	print("[faces] hull of %d face(s) -> brush %d (%d planes)" % [polys.size(), nid, planes.size() / 4])
	return nid


## Each selected face pushed out `depth` (default one grid step) as its own brush.
func faces_extrude(depth: float = -1.0) -> PackedInt32Array:
	var made := PackedInt32Array()
	var polys := _selected_face_polys()
	if polys.is_empty():
		print("[faces] extrude: select faces first (face mode, Shift-click to add)")
		return made
	if depth <= 0.0:
		depth = _grid_step()
	_persist.begin_gesture("Extrude faces to brushes")
	for p: Dictionary in polys:
		var src: PackedVector3Array = p["pts"]
		var pts := PackedVector3Array(src)
		for v: Vector3 in src:
			pts.append(v + (p["n"] as Vector3) * depth)
		var planes: PackedFloat32Array = level.hull_planes(pts)
		if planes.is_empty():
			continue
		var nid := _create_brush_journalled(_new_brush_payload(planes, int(p["brush"])))
		if nid > 0:
			made.append(nid)
	_persist.end_gesture()
	if made.is_empty():
		print("[faces] extrude: nothing was made")
		return made
	_after_new_brushes(made)
	print("[faces] extruded %d face(s) %.3f m -> %d brush(es)" % [polys.size(), depth, made.size()])
	return made


## Copy one shape (its shape.create, so it stays parametric) and link the copy into the
## source's collections — inside the caller's gesture. Returns the copy's dot ("" if refused).
## `linked` (Alt+D): the copy SHARES the source's data (ShapeFold._link_data) — its record
## names the ROOT (a link of a link names the first shape), and still carries the data as
## a snapshot, so it stands on its own if the root's create is undone.
func _dup_shape(tree: Dictionary, src: String, linked: bool = false) -> String:
	var s: Dictionary = _persist.shape_state(src)
	if s.is_empty():
		return ""
	# The RESOLVED state holds frame/size DECODED (Transform3D / Vector3); the record
	# wants the wire form the box operator writes.
	var fr: Variant = s["frame"]
	if fr is Transform3D:
		fr = SceneObjects.xform_to(fr)
	var sz: Variant = s["size"]
	if sz is Vector3:
		sz = [sz.x, sz.y, sz.z]
	var payload := {"type": s["type"], "gv": s["gv"],
		"params": s["params"], "frame": fr, "size": sz,
		"material_id": s.get("material_id", 0), "level_id": s.get("level_id", 0),
		"tex": s.get("tex", {}), "input": (s.get("input", {}) as Dictionary).duplicate(true)}
	if linked:
		var root := String(s.get("link", ""))
		payload["link"] = root if root != "" and not _persist.shape_state(root).is_empty() else src
	var sd: String = _persist.record("shape.create", payload)
	if sd != "":
		_link_copy(tree, "g:" + src, "g:" + sd)
	return sd


## Link a copy into every collection its source is in (inside the caller's gesture).
func _link_copy(tree: Dictionary, src: String, copy: String) -> void:
	for c: String in CollectionFold.collections_of(tree, src):
		_persist.record("col.link", {"target": c, "members": [copy]})


## Rebuild and hand the new brushes over as the selection, in BRUSH mode.
func _after_new_brushes(ids: PackedInt32Array) -> void:
	level.rebuild()
	_refresh_brush_view()
	_sel_faces.clear()
	_sel_edges.clear()
	_sel_mode = "brush"
	select_brushes(ids)
	_update_face_highlight()
	_update_element_highlight()


## ---- DUPLICATE (Shift+G, user 2026-10-05: "a method like in blender to duplicate") --
##
## Blender's Shift+D: copy the selection in place and pick it up. Shift+D itself is a
## strafe while flying, so it is Shift+G — "grab a copy". Objects copy type, place and
## properties; a parametric shape copies its shape.create (it stays parametric);
## brushes copy their whole state (planes, textures, flags). One gesture, so one Ctrl+Z
## takes the copies back. Then Move starts on them.
##
## THE STRUCTURE COMES ALONG (#120 F1, outliner audit bug 2), as in Blender:
##   - the copies of one brush object's brushes form ONE new object (a fresh group per
##     source group); a loose brush's copy stays loose;
##   - an object's copy keeps its parent — or, when the parent is copied too, hangs under
##     the parent's copy;
##   - every copy is linked into the collections its source is in.
##
## LINKED (Alt+D, Blender's Duplicate Linked): a shape's copy shares its DATA (generator,
## params, texture) with the source — edit either and both change; place, size, modifiers
## and cuts stay its own. A scene object already shares its asset, so it copies as with
## Shift+D. A plain brush has no data apart from its object: it is NOT copied (Make
## parametric first), and says so.
func _duplicate_one(linked: bool = false) -> bool:
	_ensure_persistence()
	var tree: Dictionary = _persist.collection_tree()
	if _sel_curve != "":
		var cd := duplicate_curve(_sel_curve)
		print("[duplicate] curve -> %s" % cd if cd != "" else "[duplicate] the journal refused the curve copy")
		return cd != ""
	if not _sel_objects.is_empty():
		var made: Array[String] = []
		var copy_of := {}                      # source dot -> its copy
		_persist.begin_gesture("Duplicate")
		for dot: String in _sel_objects:
			var n := object_node(dot) as Node3D
			if n == null:
				continue
			var nd := create_object(String(n.get_meta("object_type", "")), n.global_transform)
			if nd == "":
				continue
			var pr := object_props(dot)
			if not pr.is_empty():
				set_object_props(nd, pr)
			made.append(nd)
			copy_of[dot] = nd
		for dot: String in copy_of:
			var par := object_parent(dot)
			if par != "":
				parent_objects([copy_of[dot]], String(copy_of.get(par, par)), "Duplicate")
			_link_copy(tree, "obj:" + dot, "obj:" + String(copy_of[dot]))
		_persist.end_gesture()
		if made.is_empty():
			print("[duplicate] nothing could be copied")
			return false
		select_objects(made, made[-1])
		print("[duplicate] %d object(s)" % made.size())
	elif _sel_asset != "":
		# A PLACED ASSET (#120 L4): a new placement of the same asset, same collections —
		# Shift+D and Alt+D alike (every placement is already linked data).
		var srca := _sel_asset
		_persist.begin_gesture("Duplicate")
		var da := duplicate_asset(srca)
		_persist.end_gesture()
		if da == "":
			print("[duplicate] the journal refused the placement copy")
			return false
		_persist.refold()
		select_asset(da)
		print("[duplicate] placed asset %s -> %s" % [srca, da])
	elif _sel_shape != "":
		var src := _sel_shape
		_persist.begin_gesture("Duplicate")
		var sd := _dup_shape(tree, src, linked)
		_persist.end_gesture()
		if sd == "":
			print("[duplicate] the journal refused the shape copy")
			return false
		_persist.refold()
		select_shape(sd)
		print("[duplicate] shape %s -> %s" % [src, sd])
	elif not _sel_brushes.is_empty():
		# A SHAPE'S PIECES among the brushes copy as their SHAPE, each once (as
		# delete_brushes deletes them) — skipped, a staircase selected as its pieces
		# copied nothing and Move then took the ORIGINAL (user 2026-10-17: "shift d seems
		# to just move").
		var sp: Array = _split_shapes(_sel_brushes)
		var shapes_made: Array[String] = []
		var keys: Array[String] = []           # the brush copies, by durable key
		var new_group := {}                    # source group -> the copies' group
		_persist.begin_gesture("Duplicate")
		for d: String in (sp[0] as Dictionary):
			var sd2 := _dup_shape(tree, d, linked)
			if sd2 != "":
				shapes_made.append(sd2)
		if linked and not (sp[1] as Array).is_empty():
			print("[duplicate] linked: %d plain brush(es) skipped — they have no shared data; Make parametric first"
				% (sp[1] as Array).size())
		for b: int in ([] if linked else sp[1] as Array):
			var p: Dictionary = _persist._brush_payload(level.get_brush(b))
			var g := String(level.get_brush_group(b))
			if g != "" and not new_group.has(g):
				new_group[g] = _persist.reserve_dot()
			p["group"] = new_group.get(g, "")
			var nid := _create_brush_journalled(p)
			if nid > 0:
				keys.append(String(level.get_brush_key(nid)))
		for g: String in new_group:
			_link_copy(tree, "g:" + g, "g:" + String(new_group[g]))
		_persist.end_gesture()
		if keys.is_empty() and shapes_made.is_empty():
			print("[duplicate] nothing could be copied")
			return false
		if shapes_made.size() == 1 and keys.is_empty():
			_persist.refold()
			select_shape(shapes_made[0])
		else:
			if not shapes_made.is_empty():
				_persist.refold()                # the shapes' pieces exist after a fold
			level.rebuild()
			_refresh_brush_view()
			# A fold can renumber brush ids: the copies are found again by key.
			var ids := PackedInt32Array()
			for k: String in keys:
				var nb: int = level.brush_by_key(k)
				if nb >= 0:
					ids.append(nb)
			for sd3: String in shapes_made:
				ids.append_array(level.get_group_brush_ids(sd3))
			select_brushes(ids)
		print("[duplicate] %d brush(es), %d shape(s)" % [keys.size(), shapes_made.size()])
	else:
		print("[duplicate] select something first")
		return false
	return true


## Shift+D / Alt+D: copy what is selected, then MOVE the copies (Blender's). THE UNION
## (§17.10): each kind is copied in turn inside ONE gesture — objects, curves, then the
## brushes LAST (the other copies' folds renumber brush ids, so the source brushes are
## found again by key) — and all the copies are selected together.
func duplicate_selection(linked: bool = false) -> bool:
	var kinds := int(not _sel_brushes.is_empty()) + int(not _sel_objects.is_empty()) + int(not _sel_curves.is_empty())
	var ok := false
	if kinds > 1 or _sel_curves.size() > 1:
		ok = _duplicate_union(linked)
	else:
		ok = _duplicate_one(linked)
	if ok and _ensure_ops().activate(_move):
		print("[MapDCLevel] Move the copy: look to drag, X/Y/Z axis, Enter to place, Esc leaves it in place")
	return ok


func _duplicate_union(linked: bool) -> bool:
	_ensure_persistence()
	var objs: Array = _sel_objects.duplicate()
	var curves: Array = _sel_curves.duplicate()
	var shape := _sel_shape
	var asset := _sel_asset
	var keys: Array = []
	for b: int in _sel_brushes:
		keys.append(String(level.get_brush_key(b)))
	var made_objs: Array = []
	var made_curves: Array = []
	_persist.begin_gesture("Duplicate")
	if not objs.is_empty():
		select_objects(objs, String(objs[-1]))
		if _duplicate_one(linked):
			made_objs = selected_objects()
	for c: String in curves:
		var cd := duplicate_curve(c)
		if cd != "":
			made_curves.append(cd)
	var got_brushes := false
	if not keys.is_empty():
		if asset != "":
			select_asset(asset)
		elif shape != "":
			select_shape(shape)
		else:
			var ids := PackedInt32Array()
			for k: String in keys:
				var nb: int = level.brush_by_key(k)
				if nb >= 0:
					ids.append(nb)
			select_brushes(ids)
		got_brushes = _duplicate_one(linked)
	_persist.end_gesture()
	if not got_brushes:
		_clear_face_selection()
	for d: String in made_objs:
		select_object(d, true)
	for c2: String in made_curves:
		select_curve(c2, true)
	print("[duplicate] union: %d object(s), %d curve(s), brushes %s" % [made_objs.size(), made_curves.size(),
		"copied" if got_brushes else "none"])
	return got_brushes or not made_objs.is_empty() or not made_curves.is_empty()