OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / game / outliner_model.gd

812 lines · gd ref:
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