extends Node
## GATE — THE OUTLINER FOLLOWS THE VIEWPORT (#120 outliner audit, 2026-10-17).
## The tree's selection must never disagree with the map's: Delete and the fields act
## on the tree's rows, so a row the viewport has moved away from is a row that deletes
## the wrong thing.
## S1 click a group row, then click a wall in the viewport: the group row is no
## longer selected, and the outliner's Delete does NOT delete the whole group
## (audit bug 1: _on_map_selection followed objects only, P:96-97)
## S2 the row's OWN selection survives a refresh (every fold rebuilds the tree)
## S3 EVERY row that covers something selects exactly that in the viewport
## (user 2026-10-17: "selecting something in the organizer panel does not actually
## select it in the 3D view"): an object row its object, a shape row its shape, a
## group / collection / brush row its brushes
## S4 Ctrl+click on a selected group row DESELECTS it: nothing stays selected in the
## viewport. Godot leaves the Tree's cursor on the row just deselected
## (tree.cpp item_deselected, SELECT_MULTI), and the panel read the cursor.
## S5 Ctrl+click one of two selected object rows leaves exactly the other
## S6 an object row, then Ctrl+click a group row: the map holds objects OR brushes,
## so the group wins; the tree must show the group row, not go blank
## S7 clicking a row's EYE hides it and does NOT select it (Blender's eye never
## selects). Tree selects a selectable cell BEFORE it toggles a check cell
## (tree.cpp, the `c.selectable` block runs first), so the eye cell must not be one.
## S8 clicking a row that covers nothing (the level's own row) clears the viewport,
## so the tree and the map never name different things
## S9 VIEWPORT -> TREE (audit bug 4): a viewport click on a plain brush lights that
## brush's row (its group opened); a click on a shape's piece lights the shape row
##
## RUN AS A SCENE: "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_outliner_sync.tscn
var fails := 0
var checks := 0
var _cam: Camera3D = null
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
gs.route("world:level:*", 1, "peer")
var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
get_tree().root.get_node("/root/GameMode").set_mode("build")
var map: Node3D = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
add_child(map)
_cam = Camera3D.new()
add_child(_cam)
_cam.current = true
for i in 6:
await get_tree().process_frame
await _run(map)
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 25:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE outliner_sync: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _frames(n: int) -> void:
for i in n:
await get_tree().process_frame
## A row CLICK: in SELECT_MULTI a programmatic select() emits nothing
## (scene/gui/tree.cpp Tree::item_selected); the mouse path emits multi_selected.
func _click_row(panel: Node, id: String) -> void:
var tree: Tree = panel.get("_tree")
var it: TreeItem = (panel.get("_items") as Dictionary).get(id, null)
tree.deselect_all()
if it != null:
it.select(0)
tree.multi_selected.emit(it, 0, true)
await _frames(3)
func _selected_ids(panel: Node) -> Array:
var out: Array = []
var tree: Tree = panel.get("_tree")
var it := tree.get_next_selected(null)
while it != null:
var r: Variant = it.get_metadata(0)
if r is Dictionary:
out.append(String((r as Dictionary)["id"]))
it = tree.get_next_selected(it)
return out
## A VIEWPORT click on brush `b`, through the map's own pick (gate_shape_parts' way):
## aim straight down at the top of its bounds and click the screen centre.
func _click_brush(map: Node3D, b: int) -> void:
map.select_brushes(PackedInt32Array([b]))
var c: Vector3 = map.selection_centre()
var top: Vector3 = c + Vector3(0, map.selection_size().y * 0.5, 0)
map.clear_selection()
_cam.global_position = top + Vector3(0, 0.5, 0.01)
_cam.look_at(top, Vector3.UP)
await _frames(2)
map._pick_element(false, get_viewport().get_visible_rect().size * 0.5)
await _frames(3)
func _run(map: Node3D) -> void:
var panel: Node = map.get("_outliner")
var level: Node = map.get("level")
_ok("map_dev_test has an Outliner", panel != null)
if panel == null:
return
panel.refresh()
var rest: Dictionary = {}
for r: Dictionary in panel.model.rows():
if String(r["id"]) == "derived:rest":
rest = r
var members: PackedInt32Array = rest.get("brushes", PackedInt32Array())
_ok("setup: an Unsorted group with 2+ brushes", members.size() >= 2, members.size())
if members.size() < 2:
return
# ---- S2 first: our own row click, then a refresh ----
await _click_row(panel, "derived:rest")
_ok("setup: clicking the group row selects its brushes",
(map.get("_sel_brushes") as Array).size() == members.size(), (map.get("_sel_brushes") as Array).size())
panel.refresh()
await _frames(3)
_ok("S2 the row's own selection survives a refresh", _selected_ids(panel) == ["derived:rest"], _selected_ids(panel))
# ---- S1 then a viewport click elsewhere ----
await _click_brush(map, members[0])
var sel: Array = map.get("_sel_brushes")
_ok("setup: the viewport click picked something smaller than the group",
sel.size() >= 1 and sel.size() < members.size() or String(map.get("_sel_shape")) != "", sel)
_ok("S1 the group row is no longer selected", not _selected_ids(panel).has("derived:rest"), _selected_ids(panel))
var live0: int = (level.get_live_brush_ids() as PackedInt32Array).size()
panel.delete_selected()
await _frames(2)
var live1: int = (level.get_live_brush_ids() as PackedInt32Array).size()
_ok("S1 the outliner's Delete does not delete the whole group", live1 >= live0 - 1,
"%d -> %d (group of %d)" % [live0, live1, members.size()])
if live1 < live0: # it deleted the one brush the tree now names
map.get("_persist").undo_last()
map.get("_persist").refold()
panel.refresh()
await _frames(2)
# ---- S3 every row ----
var shape: String = map.get("_persist").record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(20, 0, 20))),
"size": [2, 1, 2], "material_id": 2, "level_id": 0})
map.get("_persist").refold()
# A room collection (#120 F1) holding the shape and a spawn point.
var cdot: String = CollectionOps.create_empty(map, "S3")
map.get("_persist").begin_gesture("Link")
map.get("_persist").record("col.link", {"target": cdot, "members": ["g:" + shape, "obj:scene:SpawnPoint1"]})
map.get("_persist").end_gesture()
map.get("_persist").refold()
var cid := "col:" + cdot
panel.refresh()
await _frames(2)
var bad: Array = []
var tried := 0
var kinds := {}
for r: Dictionary in panel.model.rows():
var want := _expect(r)
if want == "":
continue
tried += 1
kinds[want.get_slice(":", 0) + ("/col" if String(r["id"]) == cid else "")] = true
await _click_row(panel, String(r["id"]))
var got := _got(map)
if got != want:
bad.append("%s: want %s got %s" % [r["id"], want, got])
_ok("S3 rows tried include an object, a shape, a group and the collection",
kinds.has("obj") and kinds.has("shape") and kinds.has("brushes") and kinds.has("brushes/col"), "%d rows %s" % [tried, str(kinds.keys())])
_ok("S3 every row selects what it covers", bad.is_empty(), "\n ".join(bad))
# ---- S4 Ctrl+click deselect of a group row ----
panel.refresh()
await _click_row(panel, "derived:rest")
_ok("setup: the group is selected", _got(map).begins_with("brushes:"), _got(map))
await _ctrl_click_off(panel, "derived:rest")
_ok("S4 Ctrl+click deselects the group in the viewport", _got(map) == "nothing", _got(map))
_ok("S4 ... and in the tree", _selected_ids(panel).is_empty(), _selected_ids(panel))
panel.refresh()
await _frames(2)
_ok("S4 ... and a refresh does not bring the row back", _selected_ids(panel).is_empty(), _selected_ids(panel))
# ---- S5 Ctrl+click one of two object rows ----
var two: Array = []
for r: Dictionary in panel.model.rows():
if String(r.get("object_dot", "")) != "" and two.size() < 2:
two.append(r)
if two.size() == 2:
await _click_row(panel, String(two[0]["id"]))
var it2: TreeItem = (panel.get("_items") as Dictionary)[String(two[1]["id"])]
var tree: Tree = panel.get("_tree")
it2.select(0)
tree.multi_selected.emit(it2, 0, true)
await _frames(3)
_ok("setup: two objects selected", map.selected_objects().size() == 2, map.selected_objects())
await _ctrl_click_off(panel, String(two[1]["id"]))
_ok("S5 Ctrl+click leaves exactly the other object", map.selected_objects() == [String(two[0]["object_dot"])],
map.selected_objects())
# ---- S6 object row + Ctrl+click a group row ----
panel.refresh()
if two.size() >= 1:
await _click_row(panel, String(two[0]["id"]))
var git: TreeItem = (panel.get("_items") as Dictionary).get("derived:rest", null)
var tree6: Tree = panel.get("_tree")
git.select(0)
tree6.multi_selected.emit(git, 0, true)
await _frames(4)
_ok("S6 the viewport holds the group", _got(map).begins_with("brushes:"), _got(map))
_ok("S6 the tree shows the group row (only)", _selected_ids(panel) == ["derived:rest"], _selected_ids(panel))
# ---- S7 a real mouse click on a row's eye ----
panel.refresh()
await _frames(2)
map.clear_selection()
await _frames(3)
# The headless window is 64 x 64: a click at the panel's real position would land
# outside it (the dock puts the Tree ~1100 px down). Size the root so the event reaches it.
get_tree().root.size = Vector2i(1920, 2400)
# The organizer is an AREA since #124 B1, 380 px tall by default: give it the room to
# show the row, or the row sits below its visible part (a player scrolls; a gate sizes).
var dock: Node = get_tree().root.get_node("/root/BuildDock")
dock.set_pref("area.organizer", 1600)
dock.call("_apply_sizes")
await _frames(3)
var tree7: Tree = panel.get("_tree")
var it7: TreeItem = (panel.get("_items") as Dictionary).get("derived:rest", null)
tree7.scroll_to_item(it7)
await _frames(2)
var rect := tree7.get_item_area_rect(it7, 1)
print(" (S7 root %s, tree at %s size %s, eye rect %s)" % [get_tree().root.size, tree7.global_position, tree7.size, rect])
var local := rect.position + Vector2(6, rect.size.y * 0.5)
var at: Vector2 = tree7.get_global_transform() * local
var under: TreeItem = tree7.get_item_at_position(local)
print(" (S7 under the click: %s col %d; eye checked %s, selectable %s)" % [
under.get_text(0) if under != null else "none", tree7.get_column_at_position(local),
it7.is_checked(1), it7.is_selectable(1)])
var mm := InputEventMouseMotion.new()
mm.position = at
mm.global_position = at
get_viewport().push_input(mm)
await _frames(1)
print(" (S7 the control under %s: %s)" % [at, get_viewport().gui_get_hovered_control()])
for pressed: bool in [true, false]:
var mb := InputEventMouseButton.new()
mb.button_index = MOUSE_BUTTON_LEFT
mb.pressed = pressed
mb.position = at
mb.global_position = at
get_viewport().push_input(mb)
await _frames(2)
await _frames(3)
var hidden7: bool = panel.model.hidden_rows.has("derived:rest")
_ok("S7 the eye click hid the group", hidden7, panel.model.hidden_rows.keys())
_ok("S7 ... and selected nothing", _got(map) == "nothing" and _selected_ids(panel).is_empty(),
"%s %s" % [_got(map), _selected_ids(panel)])
# Leave the saved outliner as found: the eye click SAVED it (model.save) with this
# gate's hidden row in it. (Collections are journal records now, not saved here.)
panel.model.hidden_rows.erase("derived:rest")
panel.model.apply()
panel.model.save(String(panel.get("_save_key")))
# ---- S8 a row that covers nothing ----
panel.refresh()
await _click_row(panel, "derived:rest")
_ok("setup: the group is selected again", _got(map).begins_with("brushes:"), _got(map))
await _click_row(panel, "node:DCLevel")
_ok("S8 the level's row clears the viewport selection", _got(map) == "nothing", _got(map))
_ok("S8 ... and the tree shows that row", _selected_ids(panel) == ["node:DCLevel"], _selected_ids(panel))
# ---- S9 viewport -> tree ----
panel.refresh()
var rest9: Array = []
for r: Dictionary in panel.model.rows():
if String(r["id"]) == "derived:rest":
rest9 = Array(r["brushes"] as PackedInt32Array)
await _click_brush(map, int(rest9[0]))
var picked: Array = map.get("_sel_brushes")
var key9 := String(level.get_brush_key(int(picked[0]))) if picked.size() == 1 else "?"
_ok("S9 a viewport click on a brush lights exactly its brush row",
_selected_ids(panel).size() == 1 and String(_selected_ids(panel)[0]).ends_with("/brush:" + key9),
"%s for %s" % [_selected_ids(panel), key9])
var piece: int = (level.get_group_brush_ids(shape) as PackedInt32Array)[0]
map.set_select_mode("brush") # brush mode picks the SHAPE (face mode picks a part)
await _click_brush(map, piece)
_ok("setup: the click selected the shape", String(map.get("_sel_shape")) == shape,
"%s (mode %s, piece %d of shape %s)" % [_got(map), map.get("_sel_mode"), piece, shape])
# The shape's row is wherever it is LISTED: in its collection since the organizer is
# one tree (#124 B4), not also as "group:<shape>".
var shape_row := String(panel.call("_group_id", shape))
_ok("S9 a viewport click on a shape lights the shape's row", _selected_ids(panel) == [shape_row],
[_selected_ids(panel), shape_row])
map.set_select_mode("face") # (a mode switch clears the selection)
## Ctrl+click on a selected row, as Tree does it (tree.cpp, SELECT_MULTI + command):
## TreeItem.deselect, which moves the Tree's cursor ONTO that row, then multi_selected false.
func _ctrl_click_off(panel: Node, id: String) -> void:
var tree: Tree = panel.get("_tree")
var it: TreeItem = (panel.get("_items") as Dictionary).get(id, null)
if it != null:
it.deselect(0)
tree.multi_selected.emit(it, 0, false)
await _frames(3)
## What a click on row `r` must leave selected, in _got's terms; "" = covers nothing.
func _expect(r: Dictionary) -> String:
if String(r.get("object_dot", "")) != "":
return "obj:" + String(r["object_dot"])
if String(r.get("shape_dot", "")) != "":
return "shape:" + String(r["shape_dot"])
var ids: PackedInt32Array = r.get("brushes", PackedInt32Array())
if ids.is_empty():
return ""
var a: Array = Array(ids)
a.sort()
return "brushes:" + str(a)
func _got(map: Node3D) -> String:
var objs: Array = map.selected_objects()
if objs.size() == 1:
return "obj:" + String(objs[0])
if String(map.get("_sel_shape")) != "":
return "shape:" + String(map.get("_sel_shape"))
var a: Array = (map.get("_sel_brushes") as Array).duplicate()
a.sort()
return "brushes:" + str(a) if not a.is_empty() else "nothing"