OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / outliner_panel.gd

1670 lines · gd ref:
extends VBoxContainer
## THE ORGANIZER — hyper-games #28. A Tree over OutlinerModel, in the BuildDock.
##
## The model holds every rule; this holds none. That split is the point (#28 §2):
## blender-bridge has an outliner of its own and needs the same answers with no
## Godot Control in sight, so anything decided here would have to be decided
## twice. If you find yourself writing a rule in this file, it belongs next door.
##
## TWO COLUMNS. Name, and an eye. Blender has three visibility icons and the
## reason is durability — a temporary eye, a saved monitor, a render toggle. Ours
## is view-local throughout (the user's call), so there is exactly one thing to
## express and one icon to express it with. A second icon would be a lie about
## what is being stored.
##
## ONE SPECIAL CASE, and it is honest rather than hidden: the Clearance row's eye
## is INVERTED. Clearance volumes render nothing by construction — csg4 never
## sees them — so hiding them is already true and "hide" would be a no-op you
## could click forever. Their eye means SHOW THE OUTLINE, which is what Shift+V
## used to do on its own. That key stays as the shortcut; this is the row.

const COL_NAME := 0
const COL_EYE := 1
const EYE_OPEN := "◉"
const EYE_SHUT := "○"
## Blender's own convention: an inherited hide reads differently from one you
## chose, because un-hiding the parent must obviously bring the child back.
const EYE_INHERITED := "·"

var map: Node = null
var model: OutlinerModel = null

var _tree: Tree = null
var _types: OptionButton = null
var _models: OptionButton = null
var _model_rows: Array = []          # [{name, oid}] behind _models' items
var _file_dialog: FileDialog = null
var _pos: Array[SpinBox] = []
var _size: Array[SpinBox] = []
var _pos_head: Label = null
var _size_head: Label = null
## True while refresh() is writing the spinboxes, so their value_changed does not
## read back as the user having moved something.
var _syncing := false
var _status: Label = null
var _items: Dictionary = {}          # row id -> TreeItem
var _save_key := ""
## INSPECT (2026-10-05: "a clearance node that has 3511 something but i dont know what"):
## a group row OPENS to list its brushes, built only when opened and capped here.
const MEMBER_ROWS := 200
var _open_groups: Dictionary = {}    # row id -> true while opened (survives refresh)
var _building := false               # refresh() sets `collapsed`, which emits item_collapsed
## The System filter (see _build_ui); per player, a BuildDock pref.
var show_system := false
var _system_box: CheckBox = null


func setup(map_node: Node, save_key: String) -> void:
	map = map_node
	_save_key = save_key
	model = OutlinerModel.new(map)
	model.level = map.get("level")
	model.load_from(save_key)
	var dock := get_node_or_null("/root/BuildDock")
	if dock != null and dock.has_method("pref"):
		show_system = bool(dock.pref("outliner.show_system", false))
	_build_ui()
	refresh()
	# THE SCENE DRIVES THE TREE TOO: a click on an object in the viewport selects its
	# row (and a brush click clears it), so what you picked is where the panel says.
	if map.has_signal("selection_changed"):
		map.selection_changed.connect(_on_map_selection)


## The map's selection changed. Select the object's row, or drop an object row that
## is no longer what is selected. `_following` stops the row selection from being
## read back as a click (which would call select_object again).
var _following := false
var _in_refresh := false
## What the map held right after OUR row click selected it (_map_sig), so the map's
## echo of that click is told apart from a selection made in the viewport.
var _pushed_sig := ""


## The map's non-object selection, by what survives a fold: the shape's dot, or the
## brushes' durable KEYS (ids are renumbered when the level is rebuilt).
func _map_sig() -> String:
	if map == null:
		return ""
	var cd := String(map.get("_sel_curve")) if map.get("_sel_curve") is String else ""
	if cd != "":
		return "curve:" + cd
	var ad := String(map.get("_sel_asset")) if map.get("_sel_asset") is String else ""
	if ad != "":
		return "asset:" + ad
	var sd := String(map.get("_sel_shape")) if map.get("_sel_shape") is String else ""
	if sd != "":
		return "shape:" + sd
	var level: Node = map.get("level")
	var keys: Array = []
	for b: Variant in (map.get("_sel_brushes") as Array if map.get("_sel_brushes") is Array else []):
		keys.append(String(level.get_brush_key(int(b))) if level != null else str(b))
	keys.sort()
	return ",".join(keys)


## The row an object / a brush object is listed under. Since the organizer is ONE tree
## (#124 B4) an object in a collection is listed THERE ("col:<c>/obj:<dot>") and not as
## "obj:<dot>"; a brush object likewise ("col:<c>/g:<group>", not "group:<group>"). Built
## by refresh(); the first row wins for something linked into two collections.
var _obj_row: Dictionary = {}       # object dot -> row id
var _group_row: Dictionary = {}     # group key -> row id


func _obj_id(dot: String) -> String:
	return String(_obj_row.get(dot, "obj:" + dot))


func _group_id(g: String) -> String:
	return String(_group_row.get(g, "group:" + g))


func _on_map_selection() -> void:
	if _tree == null or map == null:
		return
	var dot := String(map.get("_sel_object")) if map.get("_sel_object") is String else ""
	var dots: Array = map.selected_objects() if map.has_method("selected_objects") else ([dot] if dot != "" else [])
	if not _in_refresh:
		for d: Variant in dots:
			if not _items.has(_obj_id(String(d))):
				refresh()                           # a just-created object has no row yet
				break
	# A SELECTION MADE IN THE VIEWPORT (#120 audit bugs 1 and 4). The rows our own click
	# lit stay; anything else the map now holds replaces them with the rows that name
	# it. A group row stayed selected after a click on a wall, and the outliner's Delete
	# then took the whole group, because Delete reads the rows.
	if dots.is_empty() and _map_sig() != _pushed_sig:
		_following = true
		_tree.deselect_all()
		_show_map_selection()
		_following = false
		_pushed_sig = _map_sig()
	# Already showing exactly this selection? (Our own Tree click arrives back here.)
	var shown: Array = []
	var it0 := _tree.get_next_selected(null)
	while it0 != null:
		var r: Variant = it0.get_metadata(COL_NAME)
		if r is Dictionary and String((r as Dictionary).get("object_dot", "")) != "":
			shown.append(String(r["object_dot"]))
		it0 = _tree.get_next_selected(it0)
	var a := dots.duplicate()
	a.sort()
	shown.sort()
	if a == shown and (dots.is_empty() or _selected_row_id() == _obj_id(dot)) and not (not dots.is_empty() and _holds_more()):
		return
	_following = true
	if not dots.is_empty():
		_tree.deselect_all()
	elif not shown.is_empty():
		# No objects, but object rows are lit: the map took brushes instead (objects and
		# brushes are one-or-the-other there). Drop only the object rows. Clearing all
		# blanked the tree while the viewport held the group that was just Ctrl+clicked.
		var it1 := _tree.get_next_selected(null)
		while it1 != null:
			var nx := _tree.get_next_selected(it1)
			var r1: Variant = it1.get_metadata(COL_NAME)
			if r1 is Dictionary and String((r1 as Dictionary).get("object_dot", "")) != "":
				it1.deselect(COL_NAME)
			it1 = nx
	# Others first, the ACTIVE one last: the last select() is the Tree's cursor.
	for d: Variant in dots:
		if String(d) != dot and _items.has(_obj_id(String(d))):
			(_items[_obj_id(String(d))] as TreeItem).select(COL_NAME)
	if dot != "" and _items.has(_obj_id(dot)):
		var it: TreeItem = _items[_obj_id(dot)]
		var p := it.get_parent()
		while p != null:
			p.collapsed = false
			p = p.get_parent()
		it.select(COL_NAME)
		_tree.scroll_to_item(it)
	# THE UNION (§17.10): objects AND brushes / curves held — light theirs too.
	if not dots.is_empty() and _holds_more():
		_show_map_selection()
	_following = false
	_sync_position()
	_update_status()


## Does the map hold brushes or curves besides its objects (§17.10)?
func _holds_more() -> bool:
	var bs: Variant = map.get("_sel_brushes")
	return (bs is Array and not (bs as Array).is_empty()) \
		or (map.has_method("selected_curves") and not (map.selected_curves() as Array).is_empty())


## Light the rows that name exactly what the map holds: a shape's row, or each picked
## brush's OWN row, its group opened to show it (Blender expands to the object it
## selects). Never the group row: that row covers more than was picked, and Delete on
## it would take the rest too. The active brush is selected last, so it is the cursor.
func _show_map_selection() -> void:
	# A CURVE (§16.4): its own row, a collection's row for it, or — a cable's own curve —
	# the row under its cable, which is filled only when the cable's row is open.
	# EVERY selected curve (§17.10), then whatever else is held: the selection is a union,
	# so no kind returns early.
	var cds: Array = map.selected_curves() if map.has_method("selected_curves") else []
	for cdv: Variant in cds:
		var cd := String(cdv)
		var cit: TreeItem = _items.get("curve:" + cd, null)
		if cit == null:
			for id: Variant in _items:
				if String(id).ends_with("/c:" + cd):
					cit = _items[id]
					break
		if cit == null:
			var owner: Array = model.cable_owned_curves().get(cd, [])
			var gid := _group_id(String(owner[0])) if not owner.is_empty() else ""
			var git: TreeItem = _items.get(gid, null)
			if git != null:
				_open_groups[gid] = true
				_building = true                # opening it emits item_collapsed
				git.collapsed = false
				_fill_members(git, git.get_metadata(COL_NAME) as Dictionary)
				_building = false
				cit = _items.get("%s/c:%s" % [gid, cd], null)
		if cit != null:
			_reveal(cit)
			cit.select(COL_NAME)
			_tree.scroll_to_item(cit)
	var ad := String(map.get("_sel_asset")) if map.get("_sel_asset") is String else ""
	if ad != "":
		# Its own row, or the collection row it is listed under (one tree, #124 B4).
		var ait: TreeItem = _items.get("asset:" + ad, null)
		if ait == null:
			for id: Variant in _items:
				if String(id).ends_with("/a:" + ad):
					ait = _items[id]
					break
		if ait != null:
			_reveal(ait)
			ait.select(COL_NAME)
		return
	var sd := String(map.get("_sel_shape")) if map.get("_sel_shape") is String else ""
	if sd != "":
		var sit: TreeItem = _items.get(_group_id(sd), null)
		if sit != null:
			_reveal(sit)
			sit.select(COL_NAME)
		return
	var sel: Array = map.get("_sel_brushes") if map.get("_sel_brushes") is Array else []
	var level: Node = map.get("level")
	if sel.is_empty() or level == null:
		return
	var order: Array = sel.duplicate()
	var active := int(map.get("_sel_brush")) if map.get("_sel_brush") is int else -1
	if order.has(active):
		order.erase(active)
		order.append(active)
	var last: TreeItem = null
	for b: Variant in order:
		var gid := _row_of_brush(int(b), level)
		if gid == "":
			continue
		var git: TreeItem = _items[gid]
		if not _open_groups.has(gid):
			# ONLY THE ACTIVE BRUSH'S GROUP IS OPENED, as Blender expands to the active
			# object and never to everything a box selection holds. Opening every held
			# brush's group made a new cable bundle (every cable selected) open all of its
			# groups — and every later refresh refilled them: 40 cables, 255 ms per fold
			# on tabertron, a pause per edit on ashatron (2026-10-20). Other held brushes
			# still light their rows where their group is already open.
			if int(b) != int(order.back()):
				continue
			_open_groups[gid] = true
			_building = true                    # opening it emits item_collapsed
			git.collapsed = false
			_fill_members(git, git.get_metadata(COL_NAME) as Dictionary)
			_building = false
		var bit: TreeItem = _items.get("%s/brush:%s" % [gid, String(level.get_brush_key(int(b)))], null)
		if bit != null:                         # past MEMBER_ROWS it has no row of its own
			_reveal(bit)
			bit.select(COL_NAME)
			last = bit
	if last != null:
		_tree.scroll_to_item(last)


## The group (or collection) row a brush is listed under: its operator group, else the
## derived / authored row whose brushes hold it.
func _row_of_brush(b: int, level: Node) -> String:
	var g := String(level.get_brush_group(b))
	if g != "" and _items.has(_group_id(g)):
		return _group_id(g)
	for id: Variant in _items:
		var r: Variant = (_items[id] as TreeItem).get_metadata(COL_NAME)
		if not (r is Dictionary):
			continue
		var k := int((r as Dictionary)["kind"])
		if k == OutlinerModel.Kind.GROUP and ((r as Dictionary)["brushes"] as PackedInt32Array).has(b):
			return String(id)
	return ""


func _reveal(it: TreeItem) -> void:
	var p := it.get_parent()
	_building = true
	while p != null:
		p.collapsed = false
		p = p.get_parent()
	_building = false


func _build_ui() -> void:
	# CREATE, on its own row. Godot puts node creation at the top of the scene
	# dock because it is the thing you reach for before anything else exists;
	# sharing a row with Delete would also put them a pixel apart.
	var make := HBoxContainer.new()
	add_child(make)
	_types = OptionButton.new()
	for id: String in PrefabCatalogue.ids():
		# A model needs a FILE; created bare it is a grey "no model" box. It has its
		# own row below.
		if id != "model":
			_types.add_item(id)
	_types.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	make.add_child(_types)
	var add := Button.new()
	add.text = "+ Node"
	add.pressed.connect(_on_new_node)
	make.add_child(add)

	# MODELS: import a .glb from disk, or place one the room already has (its library,
	# AssetSync kind "model"). Placing pins the file's hash, so a later re-import of the
	# same name gives NEW placements the new file and leaves existing ones alone.
	var models := HBoxContainer.new()
	add_child(models)
	_models = OptionButton.new()
	_models.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	_models.tooltip_text = "Models in this room"
	_models.get_popup().about_to_popup.connect(_refresh_models)
	models.add_child(_models)
	var place := Button.new()
	place.text = "Place"
	place.tooltip_text = "Place the chosen model at the crosshair"
	place.pressed.connect(_on_place_model)
	models.add_child(place)
	var imp := Button.new()
	imp.text = "Add model…"
	imp.tooltip_text = "Import a .glb or .gltf from this computer (checked, then shared with the room)"
	imp.pressed.connect(_on_import_model)
	models.add_child(imp)
	_refresh_models()

	# A FLOW, so the buttons wrap: as one row they set the panel's width (~600 px), and in
	# a narrower organizer area (#124) the Tree's eye column sat off the right edge.
	var bar := HFlowContainer.new()
	add_child(bar)
	for spec: Array in [["+ Collection", _on_new_collection],
			["+ Selection", _on_add_selection], ["Delete", _on_delete],
			["Select all", _on_select_all], ["Show all", _on_show_all],
			["Collapse all", collapse_all], ["Expand all", expand_all]]:
		var b := Button.new()
		b.text = spec[0]
		b.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		b.pressed.connect(spec[1])
		bar.add_child(b)

	# THE SYSTEM FILTER (Blender's outliner filter): editor / infrastructure rows show only
	# when asked. A view setting, per player — the model still lists them, so their eyes
	# and the overlays keep working. In the button flow, not a row of its own (#124
	# spacing audit item 7: four control rows sat above the Tree).
	_system_box = CheckBox.new()
	_system_box.text = "System"
	_system_box.tooltip_text = "Also list editor and infrastructure nodes (navigation, entity links, UI layers)"
	_system_box.button_pressed = show_system
	_system_box.toggled.connect(set_show_system)
	bar.add_child(_system_box)

	_tree = Tree.new()
	_tree.columns = 2
	_tree.column_titles_visible = false
	_tree.set_column_expand(COL_NAME, true)
	# THE NAME COLUMN CLIPS rather than widening the Tree to its longest label: since the
	# areas (#124) a column scrolls sideways below its content, and a 608 px Tree in a
	# ~450 px organizer put the EYE column off the right edge.
	_tree.set_column_clip_content(COL_NAME, true)
	_tree.set_column_expand(COL_EYE, false)
	_tree.set_column_custom_minimum_width(COL_EYE, 28)
	_tree.hide_root = false
	# FILLS the organizer area's height (audit item 7) rather than a fixed 260 px that
	# overflowed a short area and wasted a tall one; 120 px is the floor.
	_tree.custom_minimum_size = Vector2(0, 120)
	_tree.size_flags_vertical = Control.SIZE_EXPAND_FILL
	size_flags_vertical = Control.SIZE_EXPAND_FILL
	_tree.allow_rmb_select = true
	# MULTI: Shift / Ctrl-click select several OBJECT rows. In this mode the Tree emits
	# only multi_selected — several per click, never item_selected (scene/gui/tree.cpp
	# select_single_item) — so one deferred pass reads the whole selection afterwards.
	_tree.select_mode = Tree.SELECT_MULTI
	_tree.multi_selected.connect(func(_it: TreeItem, _c: int, _s: bool) -> void: _queue_tree_selection())
	_tree.item_selected.connect(_on_item_selected)
	_tree.item_edited.connect(_on_item_edited)
	_tree.button_clicked.connect(_on_button_clicked)
	_tree.item_collapsed.connect(_on_item_collapsed)
	# DRAG an object row onto another object to PARENT it, onto SceneObjects to unparent.
	_tree.set_drag_forwarding(_drag_data, _can_drop, _drop)
	# WHILE A TOOL IS UP ITS KEYS ARE ITS KEYS (#109: "pressing enter should apply the
	# scale"). The dock leaves a Tree FOCUS_CLICK, so the row you clicked to select a
	# brush kept the keyboard and took Enter as "activate row" before the map saw it.
	# gui_input is emitted BEFORE the Tree's own handler, so it can claim the key.
	_tree.gui_input.connect(_on_tree_gui_input)
	add_child(_tree)

	# GROUP TRANSFORM. Position only, and the label says so rather than showing
	# rotation and scale fields that do nothing: a brush is a set of half-spaces
	# and translating one is `d += n·delta`, which is exact and already journalled
	# as brush.translate. Rotation and scale are a linear map on the PLANES
	# (n -> M^-T n), which is a store primitive that does not exist and a record
	# kind that has not been decided — #22 §5 item 5. Fields that silently do
	# nothing would be worse than their absence.
	var xf := VBoxContainer.new()
	add_child(xf)
	_brush_xf = xf
	var xhead := Label.new()
	xhead.text = "Position (centre of selection)"
	xf.add_child(xhead)
	_pos_head = xhead
	var xrow := HBoxContainer.new()
	xf.add_child(xrow)
	for axis in ["X", "Y", "Z"]:
		var l := Label.new()
		l.text = axis
		xrow.add_child(l)
		var sb := SpinBox.new()
		sb.step = 0.001
		sb.min_value = -100000.0
		sb.max_value = 100000.0
		sb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		# value_changed fires while the panel is being REFRESHED too, which would
		# turn every redraw into a move. _syncing guards it.
		sb.value_changed.connect(func(_v: float) -> void: _on_position_edited())
		xrow.add_child(sb)
		_pos.append(sb)

	# SIZE, in metres, of the selection's bounding box. Typing one scales the whole
	# group about its centre — and a clearance volume is IN the group, so the cut
	# scales with the ring rather than being left behind. That is the point of
	# doing it here rather than per brush.
	var shead := Label.new()
	shead.text = "Size (units)"
	xf.add_child(shead)
	_size_head = shead
	var srow := HBoxContainer.new()
	xf.add_child(srow)
	for axis in ["X", "Y", "Z"]:
		var l := Label.new()
		l.text = axis
		srow.add_child(l)
		var sb := SpinBox.new()
		# IN QUAKE UNITS, and ratcheted to the grid.
		#
		# Metres were the wrong vocabulary: the arrows stepped 0.224 m, so taking
		# a 4 m arch to 20 m was ~70 clicks, and the number in the box ("4.256")
		# matched nothing else on screen. The create tool already says "152
		# units", the .MAP is authored on an integer lattice of them, and the grid
		# keys select 8/16/32 of them. One arrow click is now one grid step.
		#
		# Snapping still happens in _on_size_edited: geometry off the lattice is
		# geometry nothing else will ever line up with, which is the whole reason
		# the create tool snaps at all.
		sb.step = float(_units())
		sb.min_value = 0.0
		sb.max_value = 100000.0
		sb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		sb.value_changed.connect(func(_v: float) -> void: _on_size_edited())
		srow.add_child(sb)
		_size.append(sb)

	_build_object_xf()

	_status = Label.new()
	_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
	add_child(_status)
	_apply_units()
	var s := _settings()
	if s != null:
		s.setting_changed.connect(func(key: String, _v: Variant) -> void:
			if key == "editor_units" and is_instance_valid(self):
				_apply_units()
				_sync_position())


# ---- the selected OBJECT's transform (user, 2026-10-04) ------------------------
#
# The brush fields above act on a BRUSH selection (centre + bounds). An object has a
# transform of its own, so it gets its own fields, swapped in while one is selected:
#   Position, Rotation (degrees, Euler YXZ — Godot's default order)  -> object.transform
#   Scale (models only, uniform)                                     -> object.set_prop
# Absolute, like every object record: the fold replays them, so a delta would compound.

var _brush_xf: Control = null
var _obj_xf: VBoxContainer = null
var _obj_pos: Array[SpinBox] = []
var _obj_rot: Array[SpinBox] = []
var _obj_scale: SpinBox = null
var _obj_scale_row: Control = null


func _build_object_xf() -> void:
	_obj_xf = VBoxContainer.new()
	_obj_xf.visible = false
	add_child(_obj_xf)
	_obj_pos = _xyz_row("Position", 0.01, -100000.0, 100000.0, _on_object_xf_edited)
	_obj_rot = _xyz_row("Rotation (°)", 1.0, -360.0, 360.0, _on_object_xf_edited)
	var srow := HBoxContainer.new()
	var l := Label.new()
	l.text = "Scale"
	srow.add_child(l)
	_obj_scale = SpinBox.new()
	_obj_scale.min_value = 0.01
	_obj_scale.max_value = 100.0
	_obj_scale.step = 0.01
	_obj_scale.size_flags_horizontal = Control.SIZE_EXPAND_FILL
	_obj_scale.value_changed.connect(_on_object_scale_edited)
	srow.add_child(_obj_scale)
	_obj_xf.add_child(srow)
	_obj_scale_row = srow


func _xyz_row(title: String, step: float, lo: float, hi: float, cb: Callable) -> Array[SpinBox]:
	var head := Label.new()
	head.text = title
	_obj_xf.add_child(head)
	var row := HBoxContainer.new()
	_obj_xf.add_child(row)
	var out: Array[SpinBox] = []
	for axis in ["X", "Y", "Z"]:
		var a := Label.new()
		a.text = axis
		row.add_child(a)
		var sb := SpinBox.new()
		sb.step = step
		sb.min_value = lo
		sb.max_value = hi
		sb.size_flags_horizontal = Control.SIZE_EXPAND_FILL
		sb.value_changed.connect(func(_v: float) -> void: cb.call())
		row.add_child(sb)
		out.append(sb)
	return out


func _sel_object_dot() -> String:
	var v: Variant = map.get("_sel_object") if map != null else null
	return String(v) if v is String else ""


## Show the object fields (and hide the brush ones) while an object is selected, and
## fill them from the node. Positions in the player's display unit, like the brush boxes.
func _sync_object_xf() -> void:
	if _obj_xf == null:
		return
	var dot := _sel_object_dot()
	var n: Node3D = map.object_node(dot) as Node3D if dot != "" and map.has_method("object_node") else null
	_obj_xf.visible = n != null
	if _brush_xf != null:
		_brush_xf.visible = n == null
	if n == null:
		return
	var k := _disp_to_m()
	var e := n.transform.basis.get_euler()
	_syncing = true
	for i in 3:
		_obj_pos[i].suffix = _unit_suffix()
		_obj_pos[i].set_value_no_signal(n.transform.origin[i] / k)
		_obj_rot[i].set_value_no_signal(rad_to_deg(e[i]))
	var is_model := String(n.get_meta("object_type", "")) == "model"
	_obj_scale_row.visible = is_model
	if is_model:
		_obj_scale.set_value_no_signal(float(n.get("model_scale")))
	_syncing = false


func _on_object_xf_edited() -> void:
	var dot := _sel_object_dot()
	if _syncing or dot == "" or not map.has_method("move_object"):
		return
	var k := _disp_to_m()
	var pos := Vector3(_obj_pos[0].value, _obj_pos[1].value, _obj_pos[2].value) * k
	var rot := Vector3(deg_to_rad(_obj_rot[0].value), deg_to_rad(_obj_rot[1].value), deg_to_rad(_obj_rot[2].value))
	var t := Transform3D(Basis.from_euler(rot), pos)
	var n := map.object_node(dot) as Node3D
	if n != null and n.transform.is_equal_approx(t):
		return
	if map.move_object(dot, t):
		_note("object moved")
	else:
		_note("the journal refused that")


func _on_object_scale_edited(v: float) -> void:
	var dot := _sel_object_dot()
	if _syncing or dot == "" or not map.has_method("set_object_props"):
		return
	map.set_object_props(dot, {"scale": v}, "Set Scale")


## Rebuild the whole tree. Cheap because the model never emits per-brush rows —
## a level of 1183 brushes is a dozen rows, not 1183.
func refresh() -> void:
	if model == null or _tree == null:
		return
	# The selection is held by ROW ID across the rebuild. TreeItems are recreated,
	# so holding the item itself would mean the panel forgot what you had chosen
	# every time you toggled an eye.
	var keep: String = _selected_row_id()
	_building = true
	_tree.clear()
	_items.clear()
	_obj_row.clear()
	_group_row.clear()
	var rows: Array = model.rows()
	_rows = rows
	var groups: Array = []
	for r: Dictionary in rows:
		if bool(r.get("system", false)) and not show_system:
			continue
		var parent_item: TreeItem = _items.get(r["parent"], null)
		var it := _tree.create_item(parent_item)
		_items[r["id"]] = it
		var od := String(r.get("object_dot", ""))
		if od != "" and not _obj_row.has(od):
			_obj_row[od] = String(r["id"])
		var rid := String(r["id"])
		var gat := rid.find("g:") if rid.begins_with("col:") else (6 if rid.begins_with("group:") else -1)
		if gat >= 0 and int(r["kind"]) == OutlinerModel.Kind.GROUP:
			var gk := rid.substr(gat + 2) if rid.begins_with("col:") else rid.substr(6)
			if not _group_row.has(gk):
				_group_row[gk] = rid
		var label: String = r["name"]
		if int(r["count"]) > 0 and int(r["kind"]) != OutlinerModel.Kind.NODE:
			label = "%s (%d)" % [label, int(r["count"])]
		# LOCKED rows (system nodes, #109) say so and say what they are before you try.
		if bool(r.get("locked", false)):
			label += "  · locked"
			it.set_tooltip_text(COL_NAME, "%s\nLocked: it can be hidden but not deleted." % String(r.get("note", "")))
		it.set_text(COL_NAME, label)
		it.set_metadata(COL_NAME, r)
		# A collection is the only row whose name is yours to change, so it is the
		# only one that is editable — an editable derived group would invite you to
		# rename something that is recomputed from scratch on the next refresh.
		# A curve's name is yours too (§16.3): curve.set {name}.
		if int(r["kind"]) == OutlinerModel.Kind.COLLECTION or String(r.get("curve_dot", "")) != "":
			it.set_editable(COL_NAME, true)
		if String(r.get("curve_dot", "")) != "":
			it.set_tooltip_text(COL_NAME, String(r.get("note", "")))
		# A CHECK CELL, not text. The first version drew ◉/○ and toggled on
		# `get_selected_column() == COL_EYE` while ALSO calling
		# set_selectable(COL_EYE, false) — and an unselectable column can never BE
		# the selected column, so the eye was unreachable by construction. It
		# looked right and did nothing. A check cell fires item_edited when
		# clicked, which is a signal that exists whether or not the cell is
		# selectable, so the two cannot contradict each other again.
		if not bool(r.get("eye", true)):
			# No eye where hiding means nothing in the level (the map itself, editor UI).
			if bool(r["hidden"]):
				it.set_custom_color(COL_NAME, Color(0.55, 0.55, 0.6))
			continue
		it.set_cell_mode(COL_EYE, TreeItem.CELL_MODE_CHECK)
		it.set_editable(COL_EYE, true)
		# NOT SELECTABLE, so clicking the eye does not also select the row (Blender's
		# never does; #120 audit). Safe now that the toggle is item_edited: Tree runs its
		# selection block only for a selectable cell, then edits any EDITABLE one
		# (tree.cpp, `if (c.selectable)` then `if (!c.editable) return`). It was the old
		# get_selected_column toggle that unselectable broke.
		it.set_selectable(COL_EYE, false)
		var shown: bool = not bool(r["self_hidden"])
		if r["id"] == "derived:clearance":
			# INVERTED, and it is the one row that is. Clearance renders nothing by
			# construction, so its box means "draw the outline".
			shown = _clearance_shown()
		it.set_checked(COL_EYE, shown)
		# Inheritance still reads, in the NAME rather than the box: the box is what
		# this row was set to, the grey is whether it is hidden either way.
		if bool(r["hidden"]):
			it.set_custom_color(COL_NAME, Color(0.55, 0.55, 0.6))
		var k := int(r["kind"])
		# GROUP rows open to their brushes. A collection's children are real rows now
		# (sub-collections and members), so it is never filled with brush rows.
		if k == OutlinerModel.Kind.GROUP and int(r["count"]) > 0:
			groups.append(r)
	# Groups open on demand: closed ones carry one placeholder child so the arrow shows.
	for r: Dictionary in groups:
		var git: TreeItem = _items[r["id"]]
		if _open_groups.has(r["id"]):
			git.collapsed = false
			_fill_members(git, r)
		else:
			git.create_child().set_text(COL_NAME, "…")
			git.collapsed = true
	_building = false
	if keep != "" and _items.has(keep):
		(_items[keep] as TreeItem).select(COL_NAME)
	# A rebuild keeps only the cursor row; an object MULTI-selection lives in the map,
	# so put all of its rows back (every fold rebuilds this tree).
	if not _in_refresh:
		_in_refresh = true
		_on_map_selection()
		_in_refresh = false
	_sync_position()
	_update_status()


## Show where the current selection is. Blank (zero) when nothing is selected —
## there is no position to show, and leaving the last one up invites you to edit
## a field that will move something you are no longer looking at.
func _sync_position() -> void:
	if _pos.size() < 3 or map == null:
		return
	_sync_object_xf()
	var c := Vector3.ZERO
	if map.has_method("selection_centre"):
		c = map.selection_centre()
	var sz := Vector3.ZERO
	var k := _disp_to_m()
	if map.has_method("selection_size"):
		sz = map.selection_size() / k        # metres -> shown unit
	c /= k
	_syncing = true
	_apply_units()                           # the grid may have changed the step
	_pos[0].value = c.x
	_pos[1].value = c.y
	_pos[2].value = c.z
	if _size.size() >= 3:
		_size[0].value = sz.x
		_size[1].value = sz.y
		_size[2].value = sz.z
	_syncing = false


## Resize the whole selection to the bounds the fields say, about its centre.
##
## A RATIO, not an absolute — the store scales planes by a factor, so the field's
## job is to work out what factor takes the current extent to the one you typed.
## An axis whose current extent is zero cannot be scaled to anything (there is no
## factor that opens a flat thing out), so it is left alone rather than being
## given a factor of infinity.
func _on_size_edited() -> void:
	if _syncing or map == null or not map.has_method("scale_selection"):
		return
	var now: Vector3 = map.selection_size()          # metres
	var gu: float = float(_units())
	# The boxes are UNITS; the store works in metres. Snap in UNIT space so the
	# result is a whole number of grid steps of the lattice the map was authored
	# on — the field is a convenience over that lattice, not an escape from it.
	var want := Vector3.ZERO
	for i in 3:
		# shown unit -> Quake units, so the snap below happens on the map lattice
		# whichever unit the player reads.
		var u: float = _size[i].value * _disp_to_m() / _ws()
		if gu > 0.0 and u > 0.0:
			u = maxf(gu, roundf(u / gu) * gu)
		want[i] = u * _ws()                          # units -> metres
	var f := Vector3.ONE
	for i in 3:
		if now[i] > 1.0e-4 and want[i] > 1.0e-4:
			f[i] = want[i] / now[i]
	if f.is_equal_approx(Vector3.ONE):
		return
	var scaled: int = map.scale_selection(f, map.selection_centre())
	if scaled == 0:
		_note("nothing selected, or the store refused that size")
		return
	_note("scaled %d brush(es) by %s" % [scaled, str(f)])
	refresh()


## Move the whole selection so its CENTRE lands where the fields say. Absolute,
## because a centre you can read and type is what a transform panel is for — the
## delta is worked out here and handed to the map, which owns the journal write.
func _on_position_edited() -> void:
	if _syncing or map == null or not map.has_method("translate_selection"):
		return
	var target := Vector3(_pos[0].value, _pos[1].value, _pos[2].value) * _disp_to_m()
	var now: Vector3 = map.selection_centre()
	var delta: Vector3 = target - now
	if delta.length() < 1.0e-6:
		return
	var moved: int = map.translate_selection(delta)
	if moved == 0:
		_note("nothing selected, or the store refused it")
		return
	_note("moved %d brush(es) by %s" % [moved, str(delta)])
	refresh()


func _selected_row_id() -> String:
	if _tree == null:
		return ""
	return String(_selected_row().get("id", ""))


## The CURSOR row, only while it is still selected. A Ctrl+click that deselects a row
## leaves the Tree's cursor ON it (tree.cpp item_deselected, SELECT_MULTI), so the cursor
## alone re-selected what you had just taken away (#120 audit). Otherwise the last
## selected row, or {} when none is.
func _selected_row() -> Dictionary:
	var it := _tree.get_selected() if _tree != null else null
	if it != null and not it.is_selected(COL_NAME):
		it = null
		var s := _tree.get_next_selected(null)
		while s != null:
			it = s
			s = _tree.get_next_selected(s)
	if it == null:
		return {}
	var r: Variant = it.get_metadata(COL_NAME)
	return r as Dictionary if r is Dictionary else {}


# ---- the eye ----------------------------------------------------------------

## Clicking anywhere on a row selects it; clicking the EYE column toggles it.
## Godot's Tree gives the column in item_selected only via get_selected_column,
## which is why this is one handler rather than a button per row — a button per
## row on a tree that is rebuilt on every toggle leaks the handlers.
func _on_item_selected() -> void:
	var r: Dictionary = _selected_row()
	if r.is_empty() or _following:
		return
	_select_in_viewport(r)
	# THE FIELDS FOLLOW THE SELECTION. refresh() syncs them, but selecting a row
	# does not refresh — so they kept showing the previous selection's centre, and
	# typing into them moved the new selection by the difference against a number
	# that was never its own.
	_sync_position()
	_update_status()


var _tree_sel_queued := false


func _queue_tree_selection() -> void:
	if _following or _tree_sel_queued:
		return
	_tree_sel_queued = true
	_apply_tree_selection.call_deferred()


## The rows now selected -> the map. Object rows (the clicked one, or all of several)
## become the object selection, the clicked row active. Other rows: one keeps the
## single-row behaviour (a group selects its brushes, a collection its members); SEVERAL
## (Shift / Ctrl-click; user 2026-10-18: "i can't shift select many parent nodes and
## subsequent children") select every brush they cover together.
func _apply_tree_selection() -> void:
	_tree_sel_queued = false
	if _following or map == null:
		return
	var cursor: Dictionary = _selected_row()
	var dots: Array = []
	var it := _tree.get_next_selected(null)
	while it != null:
		var r: Variant = it.get_metadata(COL_NAME)
		if r is Dictionary and String((r as Dictionary).get("object_dot", "")) != "":
			dots.append(String(r["object_dot"]))
		it = _tree.get_next_selected(it)
	var cursor_dot := String(cursor.get("object_dot", ""))
	if cursor_dot != "" and map.has_method("select_objects"):
		map.select_objects(dots, cursor_dot)
		_sync_position()
		_update_status()
	elif not cursor.is_empty():
		var rows := _selected_rows()
		if rows.size() > 1 and map.has_method("select_brushes"):
			_select_rows_in_viewport(rows)
		else:
			_on_item_selected()
	elif map.has_method("clear_selection"):
		# The last row was Ctrl+clicked off: the viewport lets go too.
		map.clear_selection()
		_pushed_sig = ""
		_sync_position()
		_update_status()


## Every selected row's data, in tree order.
func _selected_rows() -> Array:
	var out: Array = []
	var it := _tree.get_next_selected(null)
	while it != null:
		var r: Variant = it.get_metadata(COL_NAME)
		if r is Dictionary:
			out.append(r)
		it = _tree.get_next_selected(it)
	return out


## The union of the rows' brushes (a collection row covers its whole subtree). The map
## holds brushes or objects, never both, so object members are not part of it.
func _select_rows_in_viewport(rows: Array) -> void:
	var ids := PackedInt32Array()
	var seen := {}
	for r: Dictionary in rows:
		for b: int in (r.get("brushes", PackedInt32Array()) as PackedInt32Array):
			if not seen.has(b):
				seen[b] = true
				ids.append(b)
	map.select_brushes(ids)
	_pushed_sig = _map_sig()
	_sync_position()
	_update_status()


## The collections whose rows are selected, by dot (Ctrl+G wraps them: a meta collection).
func selected_collections() -> Array:
	var out: Array = []
	if _tree == null:
		return out
	for r: Dictionary in _selected_rows():
		var d := String(r.get("col_dot", ""))
		if d != "" and not out.has(d):
			out.append(d)
	return out


func _on_button_clicked(_it: TreeItem, _col: int, _id: int, _mb: int) -> void:
	pass


# ---- inspect: a group row opens to its brushes ---------------------------------

func _on_item_collapsed(it: TreeItem) -> void:
	if _building:
		return
	var r: Variant = it.get_metadata(COL_NAME)
	if not (r is Dictionary):
		return
	var id := String((r as Dictionary)["id"])
	if it.collapsed:
		_open_groups.erase(id)
		return
	if int(r["kind"]) != OutlinerModel.Kind.GROUP:
		return
	_open_groups[id] = true
	# DEFERRED: this arrives inside the Tree's own mouse handling, where it refuses to
	# have items freed (the placeholder) — the same rule _toggle documents.
	_fill_open.call_deferred(id)


func _fill_open(id: String) -> void:
	var it: TreeItem = _items.get(id, null)
	if it != null and _open_groups.has(id):
		var r: Variant = it.get_metadata(COL_NAME)
		if r is Dictionary:
			_fill_members(it, r as Dictionary)


## Replace a group item's children with its brush rows (capped, plus a "more" row).
func _fill_members(git: TreeItem, r: Dictionary) -> void:
	for c in git.get_children():
		git.remove_child(c)
		c.free()
	var ids: PackedInt32Array = r.get("brushes", PackedInt32Array())
	for m: Dictionary in model.members(String(r["id"]), ids, MEMBER_ROWS):
		var it := git.create_child()
		it.set_text(COL_NAME, String(m["name"]))
		it.set_metadata(COL_NAME, m)
		if String(m.get("curve_dot", "")) != "":
			it.set_tooltip_text(COL_NAME, "the cable's curve — %s" % String(m.get("note", "")))
		else:
			it.set_tooltip_text(COL_NAME, "brush %s — click to select it" % String(m["brush_key"]))
		_items[m["id"]] = it
	if ids.size() > MEMBER_ROWS:
		var more := git.create_child()
		more.set_text(COL_NAME, "… %d more (select the group to select them all)" % (ids.size() - MEMBER_ROWS))
		more.set_selectable(COL_NAME, false)


# ---- reparenting by drag (demo sprint §3) -------------------------------------

## The selected OBJECT rows are what is dragged (several, if several are selected).
func _drag_data(_at: Vector2) -> Variant:
	var data := drag_data_for_selection()
	var n := (data["object_dots"] as Array).size() + (data["members"] as Array).size() \
		+ (data["cols"] as Array).size()
	if n == 0:
		return null
	var l := Label.new()
	l.text = "%d item%s" % [n, "" if n == 1 else "s"]
	_tree.set_drag_preview(l)
	_tree.drop_mode_flags = Tree.DROP_MODE_ON_ITEM
	return data


## What the selected rows carry when dragged (#120 F1): object dots (to parent onto an
## object row), collection MEMBERS — "obj:<dot>", "g:<group>" for a brush object or shape
## — (to move / Ctrl-link into a collection) and collection dots (to nest one collection
## in another). Derived rows (Unsorted, Sky, Clearance) are not objects and carry nothing.
func drag_data_for_selection() -> Dictionary:
	var dots: Array = []
	var members: Array = []
	var cols: Array = []
	var it := _tree.get_next_selected(null)
	while it != null:
		var r: Variant = it.get_metadata(COL_NAME)
		if r is Dictionary:
			var row: Dictionary = r
			var od := String(row.get("object_dot", ""))
			var id := String(row["id"])
			if String(row.get("asset_dot", "")) != "":
				members.append("a:" + String(row["asset_dot"]))   # a placed asset (#120 L4)
				it = _tree.get_next_selected(it)
				continue
			if String(row.get("curve_dot", "")) != "":
				members.append("c:" + String(row["curve_dot"]))   # a curve (§16.2)
				it = _tree.get_next_selected(it)
				continue
			var g := String(row.get("shape_dot", ""))
			if g == "":
				# A member row is "col:<dot>/<member>"; a collection dot has no "/".
				var slash := id.find("/") if id.begins_with("col:") else -1
				if slash >= 0 and id.substr(slash + 1).begins_with("g:"):
					g = id.substr(slash + 3)
				elif id.begins_with("group:"):
					g = id.substr(6)
			if od != "":
				dots.append(od)
				members.append("obj:" + od)
			elif String(row.get("col_dot", "")) != "":
				cols.append(String(row["col_dot"]))
			elif g != "" and int(row["kind"]) == OutlinerModel.Kind.GROUP:
				members.append("g:" + g)
		it = _tree.get_next_selected(it)
	return {"object_dots": dots, "members": members, "cols": cols}


func _drop_row_id(at: Vector2) -> String:
	var it := _tree.get_item_at_position(at)
	if it == null:
		return ""
	var r: Variant = it.get_metadata(COL_NAME)
	return String((r as Dictionary)["id"]) if r is Dictionary else ""


func _can_drop(at: Vector2, data: Variant) -> bool:
	return can_drop_on(_drop_row_id(at), data)


## Onto an object row or SceneObjects: parent objects (as before). Onto a COLLECTION row:
## move members there, or nest collections in it. Onto the map's own row: collections to
## the top level.
func can_drop_on(rid: String, data: Variant) -> bool:
	if not (data is Dictionary) or not (data as Dictionary).has("object_dots"):
		return false
	var d: Dictionary = data
	if rid.begins_with("col:") and not rid.contains("/"):
		var target := rid.substr(4)
		return not (d["members"] as Array).is_empty() \
			or (not (d["cols"] as Array).is_empty() and not (d["cols"] as Array).has(target))
	if rid == "node:.":
		return not (d["cols"] as Array).is_empty()
	if (d["object_dots"] as Array).is_empty():
		return false
	if rid == "node:SceneObjects":
		return true
	return rid.begins_with("obj:") and not (d["object_dots"] as Array).has(rid.substr(4))


func _drop(at: Vector2, data: Variant) -> void:
	_tree.drop_mode_flags = Tree.DROP_MODE_DISABLED
	drop_on(_drop_row_id(at), data, Input.is_key_pressed(KEY_CTRL))


## The drop itself. Public: the drag and the gates take the same path. `link` (Ctrl held)
## links members into a collection instead of moving them (Blender's Ctrl+drag).
func drop_on(rid: String, data: Dictionary, link: bool = false) -> int:
	if not can_drop_on(rid, data):
		_note("that cannot be dropped there")
		return 0
	if rid.begins_with("col:") or rid == "node:.":
		var target := rid.substr(4) if rid.begins_with("col:") else ""
		var n := 0
		for c: String in data["cols"]:
			if c != target:
				CollectionOps.reparent(map, c, target)
				n += 1
		if target != "" and not (data["members"] as Array).is_empty():
			if link:
				n += CollectionOps.link_members(map, target, data["members"])
			else:
				n += CollectionOps.move_members(map, target, data["members"])
		_note("%s %d into the collection" % ["linked" if link else "moved", n])
		return n
	return drop_objects(data["object_dots"] as Array, rid)


## Parent `dots` to the object on row `row_id`, or unparent them on "node:SceneObjects".
## Public: the drag and the gates take the same path. The map refuses cycles and bots.
func drop_objects(dots: Array, row_id: String) -> int:
	if map == null or not map.has_method("parent_objects"):
		return 0
	var n := 0
	if row_id == "node:SceneObjects":
		n = map.parent_objects(dots, "")
	elif row_id.begins_with("obj:"):
		n = map.parent_objects(dots, row_id.substr(4))
	_note("%s %d object(s)" % ["unparented" if row_id == "node:SceneObjects" else "parented", n] if n > 0
		else "that cannot be parented there")
	call_deferred("refresh")           # may be inside the Tree's own drop handling
	return n


## ---- BLENDER'S H / Shift+H / Alt+H (docs/cutting-and-operations.md §9c) ----
##
## THE SAME HIDDEN SET AS THE EYE: rows in model.hidden_rows, applied and saved the
## same way, so the eye shows what H did and the eye undoes it. A brush in a GROUP (a
## shape's pieces, an operator's brushes) hides by its group row — a shape's piece rows
## are renamed by every edit, so a row per piece would not survive one; an ungrouped
## brush hides by its own row. Returns how many rows changed.
func hide_brushes(ids: Array) -> int:
	var want := {}
	for b: Variant in ids:
		want[int(b)] = true
	var n := 0
	for rid: String in _rows_holding(want, true):
		if not model.hidden_rows.has(rid):
			model.set_row_hidden(rid, true)
			n += 1
	_commit_hidden()
	return n


## H on OBJECTS: their own rows (a child inherits its parent's hide, as in Blender).
func hide_objects(dots: Array) -> int:
	var n := 0
	for d: Variant in dots:
		var rid := _obj_id(String(d))          # its collection row, if it lives in one
		if not model.hidden_rows.has(rid):
			model.set_row_hidden(rid, true)
			n += 1
	_commit_hidden()
	return n


## H on CURVES (§16.4): the row each is listed under — its own, a collection's, or the one
## under its cable (hidden by id even while the cable is closed; the model applies it).
func hide_curves(dots: Array) -> int:
	var n := 0
	var owned := model.cable_owned_curves()
	for d: Variant in dots:
		var cd := String(d)
		var rid := "curve:" + cd
		if not _items.has(rid):
			for id: Variant in _items:
				if String(id).ends_with("/c:" + cd):
					rid = String(id)
					break
		if not _items.has(rid) and owned.has(cd):
			rid = "%s/c:%s" % [_group_id(String(owned[cd][0])), cd]
		if not model.hidden_rows.has(rid):
			model.set_row_hidden(rid, true)
			n += 1
	_commit_hidden()
	return n


## Shift+H: every group / ungrouped brush row that holds NONE of `ids`, and every object
## row that is not one of `keep_objects` nor an ANCESTOR of one (hiding a parent would
## hide the kept child with it — hiding inherits).
func hide_others(ids: Array, keep_objects: Array = []) -> int:
	var keep := {}
	for b: Variant in ids:
		keep[int(b)] = true
	var n := 0
	for rid: String in _rows_holding(keep, false):
		if not model.hidden_rows.has(rid):
			model.set_row_hidden(rid, true)
			n += 1
	var spare := {}
	for d: Variant in keep_objects:
		var at := String(d)
		while at != "" and not spare.has(at):
			spare[at] = true
			at = String(map.object_parent(at)) if map != null and map.has_method("object_parent") else ""
	for r: Dictionary in (model.rows() as Array):
		var od := String(r.get("object_dot", ""))
		var rid := String(r["id"])
		if od == "" or not rid.begins_with("obj:") or spare.has(od) or model.hidden_rows.has(rid):
			continue
		model.set_row_hidden(rid, true)
		n += 1
	_commit_hidden()
	return n


## Alt+H: nothing hidden. (The Clearance row is not in the hidden set — its eye is the
## wireframe toggle — so this leaves it alone.)
func unhide_all() -> int:
	var n: int = model.hidden_rows.size()
	model.hidden_rows.clear()
	_commit_hidden()
	return n


## The hide-able rows for `ids`: with `holding`, the rows that hold any of them; without,
## the rows that hold none. A hide-able row is a group row ("group:…"), or — for a brush
## in no group (Unsorted, Sky) — its MEMBER row "<derived row>/brush:<key>", which the
## model applies by key even while the row is not built (OutlinerModel.apply).
## Clearance is skipped: its eye is the wireframe toggle, not the hidden set.
func _rows_holding(ids: Dictionary, holding: bool) -> Array:
	var out: Array = []
	for r: Dictionary in (model.rows() as Array):
		var rid := String(r["id"])
		var brushes: PackedInt32Array = r.get("brushes", PackedInt32Array())
		if brushes.is_empty():
			continue
		if rid.begins_with("group:"):
			var has_any := false
			for b: int in brushes:
				if ids.has(b):
					has_any = true
					break
			if has_any == holding:
				out.append(rid)
		elif rid.begins_with("derived:") and rid != "derived:clearance":
			for b2: int in brushes:
				if ids.has(b2) == holding:
					out.append("%s/brush:%s" % [rid, String(model.level.get_brush_key(b2))])
	return out


func _commit_hidden() -> void:
	model.apply()
	model.save(_save_key)
	call_deferred("refresh")


func _toggle(r: Dictionary) -> void:
	# THE INVERTED ROW. See the header: clearance renders nothing, so its eye
	# means "draw the outline" and drives the wireframe layer instead of the
	# hidden set. Hiding it via set_brush_hidden would be a no-op.
	if r["id"] == "derived:clearance":
		if map.has_method("set_clearance_shown"):
			map.set_clearance_shown(not _clearance_shown())
		call_deferred("refresh")           # same tree-event rule as below
		return
	model.set_row_hidden(String(r["id"]), not bool(r["self_hidden"]))
	model.apply()
	model.save(_save_key)
	# DEFERRED, because this arrives from item_edited — inside Tree's own mouse
	# handling — and refresh() calls _tree.clear() + create_item(). Godot refuses
	# both there: "The tree cannot be cleared during mouse selection events". The
	# toggle worked and then took the frame down with it, which reads as the eye
	# crashing the game rather than as a rebuild in the wrong place.
	call_deferred("refresh")


## The grid in Quake units, and metres per unit. The defaults match
## map_dc_level's own constants, for the case where there is no map to ask.
func _units() -> float:
	# FLOAT since E2: the grid reaches 0.25 / 0.5 units, and int() made those 0 —
	# the size boxes then stopped snapping at all.
	if map != null and map.has_method("grid_units"):
		return float(map.grid_units())
	return 8.0


## Metres per DISPLAYED unit: the boxes show Quake units or metres per the player's
## Settings "editor_units" (editor redesign E2), so every read and write goes through
## this one factor. By path, so the panel still works where Settings is absent.
func _disp_to_m() -> float:
	var s := _settings()
	return 1.0 if (s != null and int(s.get_value("editor_units")) == 1) else _ws()


## Through the main loop's root, NOT get_node_or_null("/root/…"): setup() runs before
## the panel is in the tree, where an absolute path finds nothing — which silently
## left the units listener unconnected (gate_editor_e2 caught it).
func _settings() -> Node:
	var ml := Engine.get_main_loop() as SceneTree
	return ml.root.get_node_or_null("Settings") if ml != null else null


func _unit_suffix() -> String:
	return "m" if is_equal_approx(_disp_to_m(), 1.0) else "u"


## Suffix, header and arrow step (one grid step, in the shown unit) — re-applied when
## the units or the grid change.
func _apply_units() -> void:
	var suf := _unit_suffix()
	var step: float = float(_units()) * _ws() / _disp_to_m()
	for sb: SpinBox in _pos + _size:
		sb.suffix = suf
		sb.step = step if sb in _size else minf(step, 0.001 if suf == "m" else 0.01)
	if _pos_head != null:
		_pos_head.text = "Position (centre of selection, %s)" % ("metres" if suf == "m" else "units")
	if _size_head != null:
		_size_head.text = "Size (%s)" % ("metres" if suf == "m" else "units")


func _ws() -> float:
	if map != null and map.has_method("world_scale"):
		return float(map.world_scale())
	return 0.028


func _clearance_shown() -> bool:
	return bool(map.get("_show_clearance")) if map != null else false


# ---- selection --------------------------------------------------------------

## Clicking a row selects what it covers, in the viewport's own selection model —
## so `G` moves it with the operator that already exists rather than the outliner
## growing a second way to move things (#28 §1).
func _select_in_viewport(r: Dictionary) -> void:
	if map == null:
		return
	_select_in_viewport_rows(r)
	_pushed_sig = _map_sig()


func _select_in_viewport_rows(r: Dictionary) -> void:
	# AN AUTHORED OBJECT SELECTS AS ITSELF, by dot — this is the case #28 opened
	# with, "click on the dc-level and press g", and the dot is what makes G able
	# to journal the move afterwards.
	var dot := String(r.get("object_dot", ""))
	if dot != "" and map.has_method("select_object"):
		map.select_object(dot)
		return
	# A CURVE row selects the curve (§16.4).
	var cdot := String(r.get("curve_dot", ""))
	if cdot != "" and map.has_method("select_curve"):
		map.select_curve(cdot)
		return
	# A ◇ SHAPE ROW selects the shape, so the Shape panel follows — selecting its
	# pieces as brushes would leave _sel_shape empty and the panel blank.
	# A PLACED ASSET's row selects the placement (#120 L4).
	var adot := String(r.get("asset_dot", ""))
	if adot != "" and map.has_method("select_asset"):
		map.select_asset(adot)
		return
	var sdot := String(r.get("shape_dot", ""))
	if sdot != "" and map.has_method("select_shape"):
		map.select_shape(sdot)
		return
	var ids: PackedInt32Array = r.get("brushes", PackedInt32Array())
	if ids.is_empty():
		# A row that covers nothing (the level, a system node) lets go of the viewport:
		# leaving the old selection up had the tree and the map naming different things,
		# and G moved what the tree no longer showed (#120 audit).
		if map.has_method("clear_selection"):
			map.clear_selection()
		return
	if map.has_method("select_brushes"):
		map.select_brushes(ids)


## The rows the tree on screen was built from (refresh()). The status line counts these
## rather than calling model.rows() again: that second build was ~40 ms of a 20-cable
## bundle's ~220 ms refresh, paid twice per fold (#127), and every hide change refreshes.
var _rows: Array = []


func _update_status() -> void:
	if _status == null or model == null:
		return
	var r: Dictionary = _selected_row()
	var hidden := 0
	for row: Dictionary in _rows:
		if bool(row["self_hidden"]):
			hidden += 1
	var sel := "nothing selected"
	if not r.is_empty():
		sel = "%s — %d brush(es)" % [String(r["name"]), int(r["count"])]
	_status.text = "%s · %d row(s) hidden" % [sel, hidden]


# ---- collections ------------------------------------------------------------

## Create a node, at the crosshair. The map owns the journal write — the panel
## has no business minting dots, and a second writer of the journal is how the
## two would disagree about what a record means.
func _on_new_node() -> void:
	if _types == null or _types.item_count == 0 or map == null:
		return
	var id: String = _types.get_item_text(_types.selected)
	if not map.has_method("create_object"):
		_note("this map cannot create objects")
		return
	var dot: String = map.create_object(id, map.object_spawn_transform())
	_note("created %s" % id if dot != "" else "refused")
	refresh()


## The room's model library, re-read whenever the list is opened — the registry is
## replicated state, and a list built once at setup would never show a peer's import.
func _refresh_models() -> void:
	if _models == null:
		return
	var keep := _models.get_item_text(_models.selected) if _models.selected >= 0 else ""
	_models.clear()
	_model_rows.clear()
	var lib: Array = AssetSync.list("model") if AssetSync != null else []
	lib.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return String(a["name"]) < String(b["name"]))
	for row: Dictionary in lib:
		if not ModelCache.is_oid(String(row.get("oid", ""))):
			continue
		_model_rows.append({"name": String(row["name"]), "oid": String(row["oid"])})
		_models.add_item(String(row["name"]))
		if String(row["name"]) == keep:
			_models.select(_models.item_count - 1)
	if _models.item_count == 0:
		_models.add_item("(no models yet)")
		_models.disabled = true
	else:
		_models.disabled = false


func _on_place_model() -> void:
	if map == null or _models.selected < 0 or _models.selected >= _model_rows.size():
		_note("import a model first (Add model…)")
		return
	var row: Dictionary = _model_rows[_models.selected]
	var dot: String = map.place_model(String(row["oid"]), String(row["name"]))
	_note("placed %s" % row["name"] if dot != "" else "refused")
	refresh()


func _on_import_model() -> void:
	if map == null or not map.has_method("import_model"):
		_note("this map cannot import models")
		return
	if _file_dialog == null:
		_file_dialog = FileDialog.new()
		_file_dialog.file_mode = FileDialog.FILE_MODE_OPEN_FILE
		_file_dialog.access = FileDialog.ACCESS_FILESYSTEM
		# .gltf too: packed into one .glb at import (GltfPacker), side files included.
		_file_dialog.filters = PackedStringArray(["*.glb, *.gltf ; glTF models", "*.glb ; glTF binary", "*.gltf ; glTF + files"])
		_file_dialog.use_native_dialog = true
		_file_dialog.title = "Add model (.glb or .gltf)"
		_file_dialog.file_selected.connect(import_file)
		add_child(_file_dialog)
	_file_dialog.popup_centered_ratio(0.6)


## Import one file and report. Public so gates take the same path the dialog does.
func import_file(path: String) -> Dictionary:
	var r: Dictionary = map.import_model(path)
	if String(r.get("dot", "")) != "":
		_note("added model %s" % path.get_file())
	else:
		_note("not imported: %s" % String(r.get("reason", "?")))
	_refresh_models()
	refresh()
	return r


func _on_new_collection() -> void:
	# Nested under the selected collection when one is selected, so building a
	# hierarchy is selecting a parent and pressing the button.
	# A journal record now (#120 F1: collections are room state), so a peer sees it and
	# Ctrl+Z takes it back; the fold refreshes this tree.
	# SEVERAL selected: wrap them in a new one, as Ctrl+G does (a meta collection).
	var cols := selected_collections()
	var r: Dictionary = _selected_row()
	var parent := String(r.get("col_dot", ""))
	var make := func(n: String) -> void:
		if cols.size() > 1:
			CollectionOps.create_from_selection(map, n, "", cols)
		else:
			CollectionOps.create_empty(map, n, parent)
	# Named first (Blender's popup, 2026-10-18), through the map's prompt.
	if map.has_method("prompt_collection_name"):
		map.prompt_collection_name("New collection", make)
	else:
		make.call("Collection")


## LINK the viewport's selection — objects, shapes, brush objects — into the selected
## collection (Shift+M's job, from the panel). The one direction the panel cannot do
## alone: it has no pointer into the world.
func _on_add_selection() -> void:
	var r: Dictionary = _selected_row()
	if String(r.get("col_dot", "")) == "":
		_note("select a collection first")
		return
	var n := CollectionOps.link_selection(map, String(r["col_dot"]))
	_note("linked %d into %s" % [n, String(r["name"])] if n > 0 else "nothing selected in the viewport")


func _on_delete() -> void:
	delete_selected()


## A key on the focused Tree while a tool is active goes to the map (the tool), and the
## keyboard goes back with it (gate_confirm_focus).
## Turn the System filter on or off (the checkbox; gates). Saved per player.
func set_show_system(on: bool) -> void:
	show_system = on
	if _system_box != null and _system_box.button_pressed != on:
		_system_box.set_pressed_no_signal(on)
	var dock := get_node_or_null("/root/BuildDock")
	if dock != null and dock.has_method("set_pref"):
		dock.set_pref("outliner.show_system", on)
	refresh()


## F2 (Blender's rename): start editing the selected collection row's name in place, the
## same editor a double-click opens; the edit lands in _on_item_edited (col.set). Only a
## collection's name is yours — other rows say so. Returns whether an edit started.
func rename_selected() -> bool:
	var r: Dictionary = _selected_row() if _tree != null else {}
	var it: TreeItem = _items.get(String(r.get("id", "")), null)
	if it == null or (String(r.get("col_dot", "")) == "" and String(r.get("curve_dot", "")) == ""):
		_note("select a collection or curve row to rename (only their names are yours)")
		return false
	if not it.is_selected(COL_NAME):
		it.select(COL_NAME)
	_tree.grab_focus()
	return _tree.edit_selected(true)


func _on_tree_gui_input(event: InputEvent) -> void:
	if not (event is InputEventKey) or map == null or not map.has_method("_ensure_ops"):
		return
	var ops: Node = map.call("_ensure_ops")
	if ops == null or ops.get("active") == null:
		return
	_tree.accept_event()
	_tree.release_focus()
	map._unhandled_key_input(event)


## DELETE WHAT THE ROWS NAME (2026-10-05: "we should be able to delete nodes and
## currently cannot"). Anything SAVED IN THE ROOM goes through the map's journalled
## delete — ONE gesture, so one Ctrl+Z brings it all back, which is what makes Delete
## on a whole group safe to offer:
##   object rows (one or several selected)  -> map.delete_objects
##   a group / brush row                     -> map.delete_brushes (every brush in it)
##   a collection                            -> the collection only (view-local)
##   a plain scene node                      -> refused: it lives in the map's .tscn, so
##                                              there is no record to remove
## Public: the button and the gates take the same path.
func delete_selected() -> int:
	var r: Dictionary = _selected_row()
	if r.is_empty() or map == null:
		_note("nothing selected")
		return 0
	var dots: Array = []
	var it := _tree.get_next_selected(null)
	while it != null:
		var m: Variant = it.get_metadata(COL_NAME)
		if m is Dictionary and String((m as Dictionary).get("object_dot", "")) != "":
			dots.append(String(m["object_dot"]))
		it = _tree.get_next_selected(it)
	if not dots.is_empty() and map.has_method("delete_objects"):
		var n: int = map.delete_objects(dots)
		_note("deleted %d object(s) — Ctrl+Z brings them back" % n)
		call_deferred("refresh")
		return n
	if String(r.get("curve_dot", "")) != "" and map.has_method("delete_curve"):
		# A curve (§16.4): refused while a cable or modifier uses it (§16.7 Q2).
		var n3: int = map.delete_curve(String(r["curve_dot"]))
		_note("curve deleted — Ctrl+Z brings it back" if n3 > 0 else String(map.get("_last_refusal")))
		call_deferred("refresh")
		return n3
	if String(r.get("col_dot", "")) != "":
		# Blender's X on a collection: it goes, its contents move to its parent. Delete
		# Hierarchy (contents too) is CollectionOps.remove(..., true). One Ctrl+Z.
		CollectionOps.remove(map, String(r["col_dot"]), false)
		_note("collection deleted — its contents moved up — Ctrl+Z brings it back")
		return 1
	if OutlinerModel.is_durable(r) and map.has_method("delete_brushes"):
		var ids: Array = Array(r["brushes"] as PackedInt32Array)
		var n2: int = map.delete_brushes(ids)
		_note("deleted %d brush(es) — Ctrl+Z brings them back" % n2)
		call_deferred("refresh")
		return n2
	if bool(r.get("locked", false)):
		_note("%s is locked — %s It can be hidden, not deleted." % [String(r["name"]), String(r.get("note", ""))])
		return 0
	_note("%s is part of the map scene, not saved in the room — it cannot be deleted here" % String(r["name"]))
	return 0


## Select every live brush in the level.
func _on_select_all() -> void:
	if map == null or not map.has_method("select_all_brushes"):
		return
	map.select_all_brushes()
	_sync_position()
	_note("selected %d brush(es)" % (map.get("_sel_brushes") as Array).size())


## COLLAPSE ALL / EXPAND ALL (user 2026-10-18: "we also need a collapse all next to show
## all for collection viewing"): every row but the map's own folds (or opens). A group
## opened this way fills its brush rows the same way a click on its arrow does.
func collapse_all() -> void:
	_set_all_collapsed(true)


func expand_all() -> void:
	_set_all_collapsed(false)


func _set_all_collapsed(fold: bool) -> void:
	var root_it := _tree.get_root() if _tree != null else null
	if root_it == null:
		return
	var stack: Array = root_it.get_children()
	while not stack.is_empty():
		var it: TreeItem = stack.pop_back()
		if it.get_child_count() == 0:
			continue
		it.collapsed = fold                  # item_collapsed fills a group's members on open
		stack.append_array(it.get_children())


func _on_show_all() -> void:
	model.hidden_rows.clear()
	model.apply()
	model.save(_save_key)
	refresh()


## Both the eye and a rename arrive here — item_edited is the only signal a check
## cell emits, and the edited COLUMN is what separates them.
func _on_item_edited() -> void:
	var it := _tree.get_edited()
	if it == null:
		return
	var r: Variant = it.get_metadata(COL_NAME)
	if not (r is Dictionary):
		return
	var row: Dictionary = r as Dictionary
	if _tree.get_edited_column() == COL_EYE:
		_toggle(row)
		return
	_commit_name(it)


## A NAME EDITED IN PLACE -> its record. The editor edits the row's LABEL, which carries
## more than the name: a collection's " (<count>)" and a curve's "∿ " are taken off, or
## they were saved into the name (a collection renamed "Hall" became "Lobby (5)").
## Public for the gates: a headless Tree cannot type into its editor.
func _commit_name(it: TreeItem) -> void:
	var r: Variant = it.get_metadata(COL_NAME)
	if not (r is Dictionary):
		return
	var row: Dictionary = r
	var text := it.get_text(COL_NAME).strip_edges()
	var cdot := String(row.get("curve_dot", ""))
	if cdot != "":
		text = text.trim_prefix("∿").strip_edges()
		if text != "" and map.has_method("rename_curve"):
			map.rename_curve(cdot, text)
		return
	if String(row.get("col_dot", "")) == "":
		return                             # only a collection's or a curve's name is yours
	var n := int(row.get("count", 0))
	if n > 0:
		text = text.trim_suffix(" (%d)" % n).strip_edges()
	if text != "":
		CollectionOps.rename(map, String(row["col_dot"]), text)


func _note(text: String) -> void:
	if _status != null:
		_status.text = text