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