extends Node
## GATE — THE ORGANIZER AS ONE TREE (hyper-games #124 B4; docs/node-tree-and-stacks.md
## §2.5, Blender's View Layer; user 2026-10-18: "we also need a collapse all next to show
## all for collection viewing … the way we are laying out collections is confusing").
## O1 collections come FIRST under the map's row (a new one is not at the bottom)
## O2 an object in a collection is listed there ONLY, not also under SceneObjects
## O3 a brush object in a collection is listed there only, not also under DCLevel
## O4 things in no collection stay where they were (SceneObjects / DCLevel)
## O5 the viewport selecting an object that lives in a collection lights ITS row
## O6 H on that object hides it (the hidden set names the row it really has)
## O7 Collapse all folds every row but the map's; Expand all opens them again
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_outliner_tree.tscn
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var panel: Node
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-66s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
get_tree().root.size = Vector2i(1920, 1080)
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")
map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
add_child(map)
for i in 6:
await get_tree().process_frame
await _run()
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 9:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE outliner_tree: %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
func _ids() -> Array:
return (panel.model.rows() as Array).map(func(r: Dictionary) -> String: return String(r["id"]))
func _row(id: String) -> Dictionary:
for r: Dictionary in (panel.model.rows() as Array):
if String(r["id"]) == id:
return r
return {}
func _run() -> void:
persist = map.get("_persist")
level = map.get("level")
panel = map.get("_outliner")
# Two objects: A goes into a collection, B stays out. Two loose brushes: one into it.
var a: String = map.create_object("marker", Transform3D(Basis(), Vector3(5, 1, 5)))
var b: String = map.create_object("marker", Transform3D(Basis(), Vector3(7, 1, 5)))
var loose: Array = []
for x: int in (level.get_live_brush_ids() as PackedInt32Array):
if String(level.get_brush_group(x)) == "":
loose.append(x)
_ok("setup: two objects and two loose brushes", a != "" and b != "" and loose.size() >= 2)
if loose.size() < 2:
return
map.select_objects([a], a)
var col := CollectionOps.create_from_selection(map, "Things")
map.select_brushes(PackedInt32Array([int(loose[0])]))
CollectionOps.link_selection(map, col)
persist.refold()
await _frames(2)
var g0 := String(level.get_brush_group(int(loose[0])))
var ids := _ids()
# ---- O1 ----
var ci := ids.find("col:" + col)
var first_child := -1
for i in ids.size():
if String(_row(String(ids[i])).get("parent", "")) == "node:.":
first_child = i
break
_ok("O1 the collection is the map's FIRST child row", ci >= 0 and ci == first_child, [ci, first_child, ids.slice(0, 4)])
# ---- O2 / O3 / O4 ----
_ok("O2 object A (in the collection) is not also under SceneObjects", not ids.has("obj:" + a)
and ids.has("col:%s/obj:%s" % [col, a]), ids.filter(func(s: String) -> bool: return s.contains(a)))
_ok("O3 brush object g0 (in the collection) is not also under DCLevel", not ids.has("group:" + g0)
and ids.has("col:%s/g:%s" % [col, g0]), g0)
_ok("O4 object B (no collection) stays under SceneObjects", ids.has("obj:" + b)
and String(_row("obj:" + b).get("parent", "")) == "node:SceneObjects")
# ---- O5 ----
panel.refresh()
map.select_object(a)
await _frames(3)
var sel: Dictionary = panel.call("_selected_row")
_ok("O5 selecting A in the viewport lights its collection row", String(sel.get("id", "")) == "col:%s/obj:%s" % [col, a], sel.get("id"))
# ---- O6 ----
panel.hide_objects([a])
await _frames(2)
_ok("O6 H on A hides it", not (map.object_node(a) as Node3D).visible)
panel.unhide_all()
await _frames(2)
_ok("O6 ... Alt+H shows it again", (map.object_node(a) as Node3D).visible)
# ---- O7 ----
panel.refresh()
panel.call("collapse_all")
var tree: Tree = panel.get("_tree")
var root_it: TreeItem = tree.get_root()
var all_folded := not root_it.collapsed
for c: TreeItem in root_it.get_children():
if c.get_child_count() > 0 and not c.collapsed:
all_folded = false
_ok("O7 Collapse all folds every row but the map's", all_folded)
panel.call("expand_all")
var all_open := true
for c2: TreeItem in root_it.get_children():
if c2.get_child_count() > 0 and c2.collapsed:
all_open = false
_ok("O7 Expand all opens them again", all_open)
# ---- O8 (spacing audit item 7): the Tree FILLS the organizer area; the System filter
# rides in the button flow instead of a row of its own ----
var dock: Node = get_tree().root.get_node("/root/BuildDock")
await _frames(3)
var org: Rect2 = (dock._sides["organizer"]["frame"] as Control).get_global_rect()
var tr8: Rect2 = tree.get_global_rect()
var pr8: Rect2 = (panel as Control).get_global_rect()
# The panel (its Tree, then the transform fields below it) ends at the area's bottom —
# no overflow, no dead space — and the TREE is what took the spare height.
_ok("O8 the outliner ends at the organizer area's bottom (fills it, no overflow)", absf(pr8.end.y - org.end.y) <= 16.0,
[pr8.end.y, org.end.y])
_ok("O8 ... and the Tree took the spare height (above its 120 px floor)", tr8.size.y > 124.0, tr8.size.y)
var sb: Control = panel.get("_system_box")
_ok("O8 the System filter sits in the button flow, not a row of its own", sb != null and sb.get_parent() is HFlowContainer,
sb.get_parent() if sb != null else null)
panel.model.save(String(panel.get("_save_key")))