extends RefCounted
class_name OutlinerModel
## THE ORGANIZER'S TREE — hyper-games #28, extended with Blender-style
## collections, sub-collections and per-row viewability.
##
## UI-FREE ON PURPOSE. The panel is a Tree control over this; blender-bridge has
## an outliner of its own and needs the same answers without a Godot Control in
## sight. Same seam #28 §2 identified in TrenchBroom: the reusable part is the
## UI-free model, not the widget.
##
## ---------------------------------------------------------------------------
## THE FAULT LINE: two kinds of row that share almost nothing
##
## SCENE NODES DCTerrain, RtlightManager, QuakeLights, teleporters, doors.
## Real Godot nodes. They have `.visible`, a transform and a
## parent — so the collection tree for these ALREADY EXISTS and
## is free, and hiding one is an assignment.
## BRUSHES 1183 of them inside DCLevel, and NOT nodes. They render as one
## merged mesh, so there is no `.visible` to set: hiding means
## DCLevel::set_brush_hidden + a rebuild. And they have no
## grouping at all — `level_id` is flat and import-scoped.
##
## A model that treated a row as a row would be instant for eight lights and
## stall on the 1183 things you actually want to organise. So brushes are never
## enumerated as rows by default: they arrive in GROUPS, and a group only lists
## its members when something asks.
##
## ---------------------------------------------------------------------------
## WHAT IS SHARED AND WHAT IS NOT
##
## VISIBILITY is view-local (the user's call, 2026-08-29): this session's view state,
## saved per machine under user://, never replicated.
##
## COLLECTIONS are ROOM STATE since #120 F1 (user 2026-10-17): col.* journal records,
## folded by CollectionFold and read from LevelPersistence.collection_tree(), edited by
## CollectionOps. Their members are durable names — "g:<group>" (a brush object; the
## group follows a brush through every split and cut) and "obj:<dot>" — never ids,
## which are local handles that name different geometry next session.
##
## ---------------------------------------------------------------------------
## DERIVED GROUPS vs AUTHORED COLLECTIONS
##
## Derived groups are COMPUTED from what the store already knows — sky, clearance,
## carved, material — and cost nothing to keep correct because they are recomputed.
## They make a freshly imported level legible before anyone has organised anything,
## which is the state every level starts in.
##
## Collections are what you make: named, nestable, drag things into, shared with the
## room. They are the answer to "organise THIS".
##
## Both are rows and both have an eye. Only the second can be renamed or deleted.
const SAVE_DIR := "user://outliner"
const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
enum Kind { NODE, GROUP, COLLECTION, BRUSH, CURVE }
## One row. A plain Dictionary rather than a class so the panel, a gate and
## blender-bridge can all read it without importing a type.
## kind Kind
## id String stable row id, unique in the tree
## name String what to draw
## parent String parent row id, "" for a root
## brushes PackedInt32Array live brush ids this row covers (GROUP/COLLECTION/BRUSH)
## node_path NodePath the node (NODE rows only)
## hidden bool resolved, INCLUDING inheritance from ancestors
## self_hidden bool what this row itself was set to
## count int how many brushes it covers, for the label
var map: Node = null
var level: Node = null
## Row ids the user has hidden. Inheritance is resolved at build time, so this
## holds only what was explicitly toggled.
var hidden_rows: Dictionary = {}
func _init(map_node: Node = null) -> void:
map = map_node
if map != null:
level = map.get("level")
# ---- the tree ---------------------------------------------------------------
## Every row, parents before children, ready to walk straight into a Tree.
##
## ONE TREE (#124 B4; docs/node-tree-and-stacks.md §2.5, Blender's View Layer; user
## 2026-10-18: "the way we are laying out collections is confusing"): the map's row, then
## the COLLECTIONS first (they were appended last, so a new one landed at the very
## bottom), then the scene nodes. An object or brush object that is IN a collection is
## listed there only — it was shown twice, under the collection and in its old place;
## anything in no collection stays where it was (SceneObjects / DCLevel).
func rows() -> Array:
var out: Array = []
if map == null or not is_instance_valid(map):
return out
_add_node_rows(out)
_add_object_rows(out)
_add_brush_rows(out)
_add_curve_rows(out)
var cols: Array = []
_add_collection_rows(cols)
out = _one_tree(out, cols)
_resolve_hidden(out)
return out
## Collections right after the map's row; members of any collection dropped from their
## old places. An object whose PARENT object now lives only in a collection nests under
## that collection's row for it.
func _one_tree(base: Array, cols: Array) -> Array:
var member_row := {} # "obj:<dot>" / "group:<g>" -> first collection row id
for r: Dictionary in cols:
var id := String(r["id"])
var slash := id.find("/")
if slash < 0:
if String(r["parent"]) == "":
r["parent"] = "node:." # top-level collections: under the Scene
continue
var m := id.substr(slash + 1) # "obj:<dot>", "g:<group>", "a:<placement>", "c:<curve>"
var home := m if m.begins_with("obj:") else ("asset:" + m.substr(2) if m.begins_with("a:")
else ("curve:" + m.substr(2) if m.begins_with("c:") else "group:" + m.substr(2)))
if not member_row.has(home):
member_row[home] = id
var out: Array = []
if not base.is_empty():
out.append(base[0]) # the map's own row
out.append_array(cols)
for i in range(1, base.size()):
var r2: Dictionary = base[i]
if member_row.has(String(r2["id"])):
continue # it lives in a collection now
var pid := String(r2["parent"])
if member_row.has(pid):
r2["parent"] = member_row[pid] # its parent object lives in a collection
out.append(r2)
# Curves with every curve moved into collections: an empty container is noise.
if not out.any(func(r3: Dictionary) -> bool: return String(r3["parent"]) == "curves:"):
out = out.filter(func(r4: Dictionary) -> bool: return String(r4["id"]) != "curves:")
return out
## Scene objects, from the node tree — which IS their collection hierarchy, so
## there is nothing to invent and nothing to persist.
##
## The map's own children only, not the whole scene: everything below a door or a
## light is that object's business, and a tree that recursed into every
## MeshInstance3D of every rtlight would have thousands of rows nobody asked for.
##
## LOCKED (2026-10-12, #109: "canvaslayer, NavVolume, EntityLinks cannot be deleted, also
## what is entitylinks?"). A plain node row has no record to remove — it is in the map's
## .tscn or the map builds it at runtime — so Delete refuses it. The row says so up front
## (`locked`) and says what the node is (`note`). `eye` is false where hiding means
## nothing in the level (the map itself, editor UI).
func _add_node_rows(out: Array) -> void:
out.append({
"kind": Kind.NODE, "id": "node:.", "name": map.name, "parent": "",
"node_path": NodePath("."), "brushes": PackedInt32Array(), "count": 0,
"locked": true, "note": "The map itself.", "eye": false,
})
for child in map.get_children():
if child is not Node3D and child is not CanvasLayer:
continue # LevelPersistence and friends are not scenery
out.append({
"kind": Kind.NODE, "id": "node:%s" % child.name, "name": child.name,
"parent": "node:.", "node_path": map.get_path_to(child),
"brushes": PackedInt32Array(), "count": child.get_child_count(),
"locked": true, "note": system_note(child), "eye": child is Node3D,
"system": is_system(child),
})
## What a system node IS, in a sentence — the locked row's tooltip and the reason Delete
## gives. By name for the ones the maps build, by class otherwise.
const SYSTEM_NOTES := {
"NavVolume": "Bot navigation grid, built from the level and rebuilt about a second after edits. Bots find their paths on it.",
"EntityLinks": "Teleporter wiring: a line from each teleporter to where it sends you, drawn through walls in build mode (TrenchBroom's entity links).",
"CommandPalette": "The F3 command palette — editor UI, not part of the level.",
"DCLevel": "The level's brushes, drawn as one mesh. Hide or delete brushes through their groups below.",
"SceneObjects": "Holds every placed and adopted object: teleporters, spawn points, models, bots.",
}
## EDITOR / INFRASTRUCTURE, not level content (#120 F1 outliner audit: "System rows
## should sit behind a filter, not be locked clutter"): the panel lists them only with its
## System filter on. Scenery the map owns (DCTerrain, lights) and the containers the
## brushes and objects hang under (DCLevel, SceneObjects) are NOT system.
const SYSTEM_ROWS := ["NavVolume", "EntityLinks", "CommandPalette"]
static func is_system(n: Node) -> bool:
return n is CanvasLayer or SYSTEM_ROWS.has(String(n.name))
static func system_note(n: Node) -> String:
if SYSTEM_NOTES.has(String(n.name)):
return SYSTEM_NOTES[String(n.name)]
if n is CanvasLayer:
return "A screen UI layer — editor or HUD, not part of the level."
return "Part of the map's scene file or built by the map at runtime — not saved in the room."
## AUTHORED OBJECTS, one row each — the opposite call from brushes, and for the
## opposite reason. There are tens of these and each one was placed deliberately,
## so a row per object IS the useful view; a "Lights (7)" group would hide exactly
## the thing you came here to click. Brushes are 1183 and mostly nobody's
## decision, which is why they arrive grouped.
##
## Each row carries its DOT, because that is what selecting one has to hand to the
## map — a row index or a node path names something different next session.
func _add_object_rows(out: Array) -> void:
var objs: Node = map.get_node_or_null("SceneObjects")
if objs == null or not objs.has_method("entries"):
return
# NESTED BY object.parent, parents BEFORE children (the panel builds each row under its
# parent's item, so the parent must exist first) — depth-first from the top level, in
# fold order. A parent created after its child would otherwise come second.
var by_parent: Dictionary = {} # parent dot ("" = top) -> [entry]
var known: Dictionary = {}
for e: Dictionary in (objs.entries() as Array):
known[String(e["dot"])] = true
for e: Dictionary in (objs.entries() as Array):
var pd := String(e.get("parent", ""))
if not known.has(pd):
pd = ""
if not by_parent.has(pd):
by_parent[pd] = []
(by_parent[pd] as Array).append(e)
var order: Array = []
_walk_objects(by_parent, "", order, 0)
var pp: Node = map.get("_persist") if map != null else null
for e: Dictionary in order:
# A PLACED ASSET's objects are inside its row, read-only (#120 L4).
if pp != null and is_instance_valid(pp) and String(pp.instance_of(String(e["dot"]))) != "":
continue
# A model reads by its NAME ("model · chair"): ten rows that all say "model"
# cannot be told apart. Other prefabs keep their type.
var label := String(e["type"])
var mn: Variant = (e["node"] as Node).get("model_name")
if mn is String and String(mn) != "":
label = "%s · %s" % [label, String(mn)]
var pd := String(e.get("parent", ""))
out.append({
"kind": Kind.NODE, "id": "obj:%s" % String(e["dot"]),
"name": label, "parent": "obj:%s" % pd if known.has(pd) else "node:SceneObjects",
"node_path": map.get_path_to(e["node"] as Node),
"brushes": PackedInt32Array(), "count": 0,
"object_dot": String(e["dot"]),
})
func _walk_objects(by_parent: Dictionary, pd: String, order: Array, depth: int) -> void:
if depth > 256:
return
for e: Dictionary in by_parent.get(pd, []):
order.append(e)
_walk_objects(by_parent, String(e["dot"]), order, depth + 1)
## The level's brushes, as GROUPS. Never one row per brush: 1183 rows is not an
## outliner, it is a wall.
##
## CACHED per brush state (2026-10-05): every fold rebuilds the tree, and an OBJECT edit
## leaves the brushes alone — re-deriving every group cost ~12 ms per edit at 2500
## brushes. The key is the journal's last APPLIED brush fold (LevelPersistence bumps it
## on any brush/shape record change, and a direct level edit is caught by its fingerprint
## check and re-folded) and the level instance. No journal (a bare
## level in a gate): no cache.
var _brush_rows_key := ""
var _brush_rows: Array = []
func _add_brush_rows(out: Array) -> void:
var p: Node = map.get("_persist") if map != null else null
var key := ""
if p != null and is_instance_valid(p) and level != null and is_instance_valid(level) \
and "_brush_ver_folded" in p:
key = "%d|%d|%d|%d" % [int(p.get("_brush_ver_folded")), int(p.get("_brush_fp")),
level.get_instance_id(), 0]
if key != "" and key == _brush_rows_key:
for r: Dictionary in _brush_rows:
out.append(r.duplicate()) # _resolve_hidden writes into each row
return
var start := out.size()
_build_brush_rows(out)
_brush_rows = []
for i in range(start, out.size()):
_brush_rows.append((out[i] as Dictionary).duplicate())
_brush_rows_key = key
func _build_brush_rows(out: Array) -> void:
if level == null or not is_instance_valid(level) \
or not level.has_method("get_live_brush_ids"):
return
var parent := "node:%s" % (level.name if level != map else ".")
var live: PackedInt32Array = level.get_live_brush_ids()
if live.is_empty():
return
# DERIVED — recomputed every build, so they cannot go stale.
var sky: PackedInt32Array = level.get_sky_brush_ids()
var clear: PackedInt32Array = level.get_clearance_brush_ids()
var claimed := {}
for arr: PackedInt32Array in [sky, clear]:
for b: int in arr:
claimed[b] = true
_group(out, parent, "derived:sky", "Sky", sky)
_group(out, parent, "derived:clearance", "Clearance", clear)
# OPERATOR GROUPS — durable structure, and the row is COMPUTED from the key
# each brush carries rather than stored beside it. That is the whole reason it
# cannot drift: there is no membership list to fall out of step with the
# geometry. An arch is one row because nine brushes say they are one arch.
#
# These claim their brushes, so a grouped shape leaves Unsorted — an arch you
# built should read as an arch, not as nine loose walls.
#
# A PARAMETRIC SHAPE's group is keyed by the shape's dot. Its row says so with ◇ —
# the mark the Add menu and the Shape panel already use — and carries `shape_dot`, so
# clicking it selects the SHAPE (Shape panel and all) rather than a pile of
# read-only pieces (user 2026-10-05: "would be easier for selecting").
var p: Node = map.get("_persist") if map != null else null
var shapes: Dictionary = {}
if p != null and is_instance_valid(p) and p.has_method("live_shapes"):
shapes = p.live_shapes()
# PLACED ASSETS (#120 L4 §15.3): ONE row each, "▣ <name>", covering every piece of the
# placement (its synthetic groups are renamed by every move, so they get no rows).
if p != null and is_instance_valid(p) and p.has_method("placements"):
var pls: Array = (p.placements() as Dictionary).values()
pls.sort_custom(func(x: Dictionary, y: Dictionary) -> bool: return String(x["dot"]) < String(y["dot"]))
for pl: Dictionary in pls:
if bool(pl["removed"]):
continue
var aids: PackedInt32Array = map.asset_brush_ids(String(pl["dot"])) if map.has_method("asset_brush_ids") else PackedInt32Array()
for b2: int in aids:
claimed[b2] = true
out.append(_asset_row("asset:%s" % pl["dot"], parent, pl, aids))
if level.has_method("get_group_keys"):
for gk: String in (level.get_group_keys() as PackedStringArray):
var members: PackedInt32Array = level.get_group_brush_ids(gk)
if members.is_empty():
continue
if p != null and is_instance_valid(p) and p.has_method("instance_of") and String(p.instance_of(gk)) != "":
continue # a placement's piece: in its row above
for b: int in members:
claimed[b] = true
var row := {
"kind": Kind.GROUP, "id": "group:%s" % gk,
"name": _group_label(members), "parent": parent,
"brushes": members, "count": members.size(),
"node_path": NodePath(),
}
if shapes.has(gk):
row["name"] = "◇ " + _shape_label(p, gk)
row["shape_dot"] = gk
out.append(row)
# WHAT IS LEFT. Not a category — the ABSENCE of one — and it is named
# honestly so an unorganised level does not look organised.
var rest := PackedInt32Array()
for b: int in live:
if not claimed.has(b):
rest.append(b)
_group(out, parent, "derived:rest", "Unsorted", rest)
## CURVES (#120 §16.3): a curve is an object with no brushes, one row each, "∿ <name>",
## under a Curves row like DCLevel's and SceneObjects'. A curve that ONE cable uses (its
## path or its profile) is that cable's DATA (§16.7 Q1): it has no row here, and is listed
## first when the cable's row opens (members()). One used by several things, or by a
## modifier, is a row of its own. Curves has no row when nothing is in it.
func _add_curve_rows(out: Array) -> void:
var p: Node = map.get("_persist") if map != null else null
if p == null or not is_instance_valid(p) or not p.has_method("curve_states"):
return
var curves: Dictionary = p.curve_states()
if curves.is_empty():
return
var owned := cable_owned_curves()
var dots: Array = curves.keys()
dots.sort()
var rows: Array = []
for d: String in dots:
if not owned.has(d):
rows.append(_curve_row("curve:" + d, "curves:", d, curves[d], ""))
if rows.is_empty():
return
out.append({"kind": Kind.GROUP, "id": "curves:", "name": "Curves", "parent": "node:.",
"brushes": PackedInt32Array(), "count": 0, "node_path": NodePath(),
"note": "Every curve in no collection. A cable's own curve is listed under the cable."})
out.append_array(rows)
## The curves that are ONE cable's data: {curve dot: [cable dot, "path" | "profile"]}.
func cable_owned_curves() -> Dictionary:
var p: Node = map.get("_persist") if map != null else null
if p == null or not is_instance_valid(p) or not p.has_method("curve_users_all"):
return {}
var roles := {}
var users: Dictionary = p.curve_users_all(roles)
var shapes: Dictionary = p.live_shapes()
var out := {}
for c: String in users:
var us: Array = users[c]
if us.size() == 1 and shapes.has(String(us[0])) \
and String((p.shape_state(String(us[0])) as Dictionary).get("type", "")) == "cable":
out[c] = [String(us[0]), String(roles.get("%s|%s" % [c, us[0]], "path"))]
return out
## A curve's row. `role` ("path" / "profile") names it under its cable.
func _curve_row(id: String, parent: String, dot: String, c: Dictionary, role: String) -> Dictionary:
var label := "∿ " + String(c.get("name", "Curve"))
if role != "":
label = "∿ %s · %s" % [role, String(c.get("name", "Curve"))]
return {"kind": Kind.CURVE, "id": id, "name": label, "parent": parent,
"brushes": PackedInt32Array(), "count": 0, "node_path": NodePath(), "curve_dot": dot,
"note": "%d point(s)%s" % [(c["points"] as Array).size(), ", closed" if bool(c.get("closed", false)) else ""]}
## COLLECTIONS — the ROOM's (#120 F1): journalled, replicated, folded by CollectionFold
## and read from LevelPersistence.collection_tree(). Top-level collections are roots, as
## Blender's sit under the Scene Collection. Each lists its child collections and its
## MEMBERS: a brush object ("g:<group>") as a group row, a scene object ("obj:<dot>") as
## an object row. A member linked into two collections appears under both — the same
## object, so its rows carry the same brushes / dot — and its row id is prefixed with the
## collection's ("col:<dot>/g:<group>") to stay unique in the tree. A member whose object
## is gone is not shown. A collection row covers its whole subtree's brushes, so clicking
## it selects everything in it (Blender's Select Objects on a collection).
func _add_collection_rows(out: Array) -> void:
var p: Node = map.get("_persist") if map != null else null
if p == null or not is_instance_valid(p) or not p.has_method("collection_tree"):
return
var t: Dictionary = p.collection_tree()
var cols: Dictionary = t["cols"]
var shapes: Dictionary = p.live_shapes() if p.has_method("live_shapes") else {}
var curves: Dictionary = p.curve_states() if p.has_method("curve_states") else {}
var objs: Node = map.get_node_or_null("SceneObjects")
var obj_by_dot := {}
if objs != null and objs.has_method("entries"):
for e: Dictionary in (objs.entries() as Array):
obj_by_dot[String(e["dot"])] = e
var roots: Array = []
for d: String in cols:
if String(cols[d]["parent"]) == "":
roots.append(d)
roots.sort()
var order: Array = []
var stack: Array = roots.duplicate()
stack.reverse()
while not stack.is_empty():
var d: String = stack.pop_back()
order.append(d)
var kids: Array = (cols[d]["children"] as Array).duplicate()
kids.reverse()
stack.append_array(kids)
for d: String in order:
var c: Dictionary = cols[d]
var cid := "col:" + d
var all := PackedInt32Array()
var seen := {}
for m: String in CollectionFold.members_of(t, d, true):
if m.begins_with("g:") and level != null and is_instance_valid(level):
for b: int in (level.get_group_brush_ids(m.substr(2)) as PackedInt32Array):
if not seen.has(b):
seen[b] = true
all.append(b)
elif m.begins_with("a:") and map.has_method("asset_brush_ids"):
for b3: int in (map.asset_brush_ids(m.substr(2)) as PackedInt32Array):
if not seen.has(b3):
seen[b3] = true
all.append(b3)
out.append({
"kind": Kind.COLLECTION, "id": cid, "name": String(c["name"]),
"parent": "col:" + String(c["parent"]) if String(c["parent"]) != "" else "",
"brushes": all, "count": all.size(), "node_path": NodePath(), "col_dot": d,
})
var pls2: Dictionary = p.placements() if p.has_method("placements") else {}
for m: String in (c["members"] as Array):
if m.begins_with("a:"):
var pl2: Dictionary = pls2.get(m.substr(2), {})
if pl2.is_empty() or bool(pl2["removed"]):
continue
var aids2: PackedInt32Array = map.asset_brush_ids(m.substr(2)) if map.has_method("asset_brush_ids") else PackedInt32Array()
out.append(_asset_row("%s/%s" % [cid, m], cid, pl2, aids2))
continue
if m.begins_with("c:"):
var cv: Dictionary = curves.get(m.substr(2), {})
if not cv.is_empty():
out.append(_curve_row("%s/%s" % [cid, m], cid, m.substr(2), cv, ""))
continue
if m.begins_with("obj:"):
var od := m.substr(4)
if not obj_by_dot.has(od):
continue
var e: Dictionary = obj_by_dot[od]
out.append({
"kind": Kind.NODE, "id": "%s/%s" % [cid, m], "name": String(e["type"]),
"parent": cid, "node_path": map.get_path_to(e["node"] as Node),
"brushes": PackedInt32Array(), "count": 0, "object_dot": od,
})
elif m.begins_with("g:") and level != null and is_instance_valid(level):
var g := m.substr(2)
var ids: PackedInt32Array = level.get_group_brush_ids(g)
if ids.is_empty():
continue
var row := {
"kind": Kind.GROUP, "id": "%s/%s" % [cid, m],
"name": _group_label(ids), "parent": cid,
"brushes": ids, "count": ids.size(), "node_path": NodePath(),
}
if shapes.has(g):
row["name"] = "◇ " + _shape_label(p, g)
row["shape_dot"] = g
out.append(row)
## A placed asset's row (#120 L4): selecting it selects the placement (`asset_dot`).
func _asset_row(id: String, parent: String, pl: Dictionary, ids: PackedInt32Array) -> Dictionary:
return {"kind": Kind.GROUP, "id": id, "name": "▣ " + String(pl["name"]), "parent": parent,
"brushes": ids, "count": ids.size(), "node_path": NodePath(), "asset_dot": String(pl["dot"])}
## A shape's type label ("Stairs"), from its resolved state.
func _shape_label(p: Node, dot: String) -> String:
var tid := String((p.shape_state(dot) as Dictionary).get("type", ""))
var t: Object = ParamShapes.get_type(tid) if tid != "" else null
return String(t.label()) if t != null else (tid if tid != "" else "shape")
## What to call an operator group. There is no name in the record — the shape is
## implied by what it contains — so it is read back from the geometry: a group
## with a clearance volume is a passage, one without is a plain solid. Cheaper
## and more honest than journalling a label that could disagree with the brushes.
func _group_label(members: PackedInt32Array) -> String:
# One narrow read, not a get_brush per member: every fold labels every group, and
# that was ~40 ms per pass at a 20-cable bundle (#127).
var clearance: int = level.count_clearance(members)
var solids: int = members.size() - clearance
if clearance > 0:
return "passage (%d + cut)" % solids
return "group (%d)" % solids
func _group(out: Array, parent: String, id: String, name: String,
ids: PackedInt32Array) -> void:
if ids.is_empty():
return # an empty derived group is noise
out.append({
"kind": Kind.GROUP, "id": id, "name": name, "parent": parent,
"brushes": ids, "count": ids.size(), "node_path": NodePath(),
})
## WHAT IS IN A GROUP, one BRUSH row each — only when asked (a row is opened), and
## capped: the whole point of groups is that 3511 rows are never built up front.
## Each row names its brush by durable KEY (the dot that made it: "i:1:4" for a scene
## box, "<author>:<seq>" for an edit) plus its size, so "Clearance (3511)" can be read.
## `parent_id` keeps row ids unique when one brush sits in a group AND a collection.
func members(parent_id: String, ids: PackedInt32Array, limit: int) -> Array:
var out: Array = []
if level == null or not is_instance_valid(level):
return out
# A CABLE's own curve first (§16.7 Q1): its data, "∿ path · <name>" — the row the curve
# has instead of one under Curves. The cable is the group's key, in its own row
# ("group:<cable>") or in a collection's ("col:<c>/g:<cable>").
var at := parent_id.rfind("/g:")
var gk := parent_id.substr(6) if parent_id.begins_with("group:") else (parent_id.substr(at + 3) if at >= 0 else "")
if gk != "":
var owned := cable_owned_curves()
var cs: Dictionary = (map.get("_persist").curve_states() as Dictionary) if not owned.is_empty() else {}
var mine: Array = owned.keys().filter(func(c: String) -> bool: return String(owned[c][0]) == gk)
mine.sort_custom(func(x: String, y: String) -> bool: return String(owned[x][1]) < String(owned[y][1]))
for c2: String in mine:
out.append(_curve_row("%s/c:%s" % [parent_id, c2], parent_id, c2, cs[c2], String(owned[c2][1])))
# A PARAMETRIC SHAPE's pieces are named by ROLE — "step 3", "landing 1", "voussoir 4",
# "passage" for the cut (§13.4) — and listed in the generator's order, not by key text
# (which put piece 10 before piece 2). The role comes from regenerating the shape
# (only when the group is opened); the piece index is in its fold name
# "<author>/<i>@<ver>:<seq>" (shape_fold.gd).
var roles: Array = _shape_roles(parent_id.trim_prefix("group:")) if parent_id.begins_with("group:") else []
if not roles.is_empty():
var order: Array = Array(ids)
order.sort_custom(func(x: int, y: int) -> bool: return _piece_index(x) < _piece_index(y))
ids = PackedInt32Array(order)
for i in mini(ids.size(), limit):
var b: int = ids[i]
var d: Dictionary = level.get_brush(b)
var key := String(level.get_brush_key(b))
var pi := _piece_index(b) if not roles.is_empty() else -1
if pi >= 0 and pi < roles.size():
var sz := Vector3.ZERO
if d.has("aabb_min"):
sz = (d["aabb_max"] as Vector3) - (d["aabb_min"] as Vector3)
out.append({
"kind": Kind.BRUSH, "id": "%s/brush:%s" % [parent_id, key],
"name": "%s · %.2f×%.2f×%.2f m" % [String(roles[pi]), sz.x, sz.y, sz.z],
"parent": parent_id, "brushes": PackedInt32Array([b]), "count": 1,
"node_path": NodePath(), "brush_key": key,
})
continue
var size := Vector3.ZERO
if d.has("aabb_min"):
size = (d["aabb_max"] as Vector3) - (d["aabb_min"] as Vector3)
var tags := PackedStringArray()
if bool(d.get("clearance", false)):
tags.append("cut")
if bool(d.get("sky", false)):
tags.append("sky")
tags.append("mat %d" % int(d.get("material_id", 0)))
out.append({
"kind": Kind.BRUSH, "id": "%s/brush:%s" % [parent_id, key],
"name": "%s · %.2f×%.2f×%.2f m · %s" % [key, size.x, size.y, size.z, ", ".join(tags)],
"parent": parent_id, "brushes": PackedInt32Array([b]), "count": 1,
"node_path": NodePath(), "brush_key": key,
})
return out
## The roles of a live shape's pieces (ParamShape.roles), or [] when `dot` is not one.
func _shape_roles(dot: String) -> Array:
var p: Node = map.get("_persist") if map != null else null
if p == null or not p.has_method("shape_state"):
return []
var st: Dictionary = p.shape_state(dot)
if st.is_empty():
return []
var t: Object = ParamShapes.get_type(String(st.get("type", "")))
if t == null:
return []
var out: Array = t.roles(st.get("size", Vector3.ONE), t.resolve(st.get("params", {})))
# An assigned generator's INPUT comes out as passage pieces after the type's own.
var inp: Dictionary = st.get("input", {})
var ti: Object = ParamShapes.get_type(String(inp.get("type", ""))) if not inp.is_empty() else null
if ti != null:
for _pc in ti.local_pieces(st.get("size", Vector3.ONE), ti.resolve(inp.get("params", {}))):
out.append("passage")
return out
## A shape piece's index from its fold name "<author>/<i>@<ver>:<seq>"; -1 otherwise.
func _piece_index(b: int) -> int:
var key := String(level.get_brush_key(b))
var at := key.find("@")
var slash := key.rfind("/", at)
if at < 0 or slash < 0:
return -1
var s := key.substr(slash + 1, at - slash - 1)
return int(s) if s.is_valid_int() else -1
## Whether deleting this row would delete something SAVED IN THE ROOM (and so can be
## journalled and undone): an object, or brushes. A plain scene node is part of the
## map's .tscn — there is no record to remove, and freeing it would come back on reload.
static func is_durable(r: Dictionary) -> bool:
if String(r.get("object_dot", "")) != "":
return true
var k := int(r.get("kind", Kind.NODE))
return (k == Kind.GROUP or k == Kind.BRUSH) and not (r.get("brushes", PackedInt32Array()) as PackedInt32Array).is_empty()
## Hiding INHERITS: hide a collection and its contents go with it, which is what
## makes hiding a collection worth doing. Resolved here rather than stored, so
## un-hiding a parent restores exactly the children that were not hidden
## themselves — the state Blender keeps and the reason its eye and its tick are
## two different controls.
func _resolve_hidden(out: Array) -> void:
var by_id := {}
for r: Dictionary in out:
by_id[r["id"]] = r
for r: Dictionary in out:
r["self_hidden"] = hidden_rows.has(r["id"])
var h: bool = r["self_hidden"]
var p: String = r["parent"]
var guard := 0
while not h and p != "" and by_id.has(p) and guard < 64:
h = hidden_rows.has(p)
p = by_id[p]["parent"]
guard += 1
r["hidden"] = h
# ---- visibility -------------------------------------------------------------
## Toggle a row. Returns true if the level needs a rebuild — the caller batches,
## because hiding a collection of 200 brushes is 200 set_brush_hidden calls and
## ONE rebuild.
func set_row_hidden(row_id: String, hidden: bool) -> void:
if hidden:
hidden_rows[row_id] = true
else:
hidden_rows.erase(row_id)
## Push the resolved state onto the world. ONE rebuild at the end, always.
##
## THE REBUILD IS GATED ON THE SET HAVING CHANGED, and it has to be, because the
## obvious gate is wrong in exactly one case. Rebuilding when "something is
## hidden" skips the LAST unhide: by then nothing is hidden, so the condition is
## false, so the mesh keeps the state you just cleared. Everything looks correct
## until you unhide the final row and it stays gone. Comparing before against
## after has no such hole and costs one array compare.
func apply() -> void:
var rs: Array = rows()
var live: bool = level != null and is_instance_valid(level) \
and level.has_method("clear_hidden")
var before := PackedInt32Array()
if live:
before = level.get_hidden_brush_ids()
level.clear_hidden()
for r: Dictionary in rs:
if not r["hidden"]:
continue
for b: int in (r["brushes"] as PackedInt32Array):
level.set_brush_hidden(b, true)
# MEMBER rows ("<group>/brush:<key>") are built only when their group is opened
# (members()), so rows() never holds them and a hidden one was never applied.
# Resolved by the brush's durable KEY instead (H on an ungrouped brush, §9c).
for hid: String in hidden_rows:
var at := hid.find("/brush:")
if at < 0:
continue
var mb := int(level.brush_by_key(hid.substr(at + "/brush:".length())))
if mb > 0:
level.set_brush_hidden(mb, true)
# A node can have SEVERAL rows (an object linked into two collections): it is hidden
# if ANY of them is. Applied row by row, the last row won, and an object H had hidden
# came straight back through its other row.
var node_hidden := {} # Node -> bool
for r: Dictionary in rs:
if r["kind"] != Kind.NODE:
continue
var n: Node = map.get_node_or_null(r["node_path"] as NodePath)
# The map itself is row "node:." and hiding it would take the whole scene
# with it, including the dock you clicked in.
if n == null or n == map or n is not Node3D:
continue
node_hidden[n] = bool(node_hidden.get(n, false)) or bool(r["hidden"])
for n2: Node in node_hidden:
(n2 as Node3D).visible = not bool(node_hidden[n2])
# For a node something else also shows and hides (EntityLinks on every mode
# change): that code reads this, or it undoes the eye.
n2.set_meta("outliner_hidden", bool(node_hidden[n2]))
# CURVES (§16.4): a curve row hidden (its own eye, or a hidden collection above it) — or
# a cable's own curve, whose row exists only while the cable is open: hidden by its id
# "<cable row>/c:<dot>" or with any row of its cable. Any one of its rows hides it.
if map.has_method("set_hidden_curves"):
var hc := {}
var hidden_ids := {}
for r: Dictionary in rs:
if bool(r["hidden"]):
hidden_ids[String(r["id"])] = true
if String(r.get("curve_dot", "")) != "":
hc[String(r["curve_dot"])] = true
var owned := cable_owned_curves()
for c: String in owned:
var cable := String(owned[c][0])
for hid2: String in hidden_rows:
if hid2.ends_with("/c:" + c):
hc[c] = true
for rid: String in hidden_ids:
if rid == "group:" + cable or rid.ends_with("/g:" + cable):
hc[c] = true
map.set_hidden_curves(hc.keys())
# EntityLinks is also an OVERLAY (keymap step 6.5): the map recomputes it from the eye,
# the mode and the overlay switches, or unhiding would show links whose overlay is off.
if map.has_method("_apply_overlays"):
map._apply_overlays()
if live and before != level.get_hidden_brush_ids():
level.rebuild()
if map.has_method("_refresh_brush_view"):
map._refresh_brush_view()
# ---- persistence (view-local, per machine) ----------------------------------
func _save_path(key: String) -> String:
return "%s/%s.dat" % [SAVE_DIR, key if key != "" else "default"]
func save(key: String) -> void:
DirAccess.make_dir_recursive_absolute(SAVE_DIR)
var f := FileAccess.open(_save_path(key), FileAccess.WRITE)
if f == null:
push_warning("[Outliner] cannot write %s — hidden rows will not survive" % _save_path(key))
return
f.store_var({
"version": 1, "hidden": hidden_rows,
}, true)
f.close()
## Any failure loads NOTHING rather than half a tree: an outliner is a
## convenience, and one that can corrupt the session it is organising is worse
## than one that forgets.
func load_from(key: String) -> bool:
var f := FileAccess.open(_save_path(key), FileAccess.READ)
if f == null:
return false
var v: Variant = f.get_var(true)
f.close()
if not (v is Dictionary) or int((v as Dictionary).get("version", 0)) != 1:
return false
var d: Dictionary = v
# An old file's per-machine "collections" are not read: collections are room state
# now (#120 F1; dropping the per-machine ones was the user's call, 2026-10-17).
hidden_rows = d.get("hidden", {})
return true