extends RefCounted
class_name CollectionOps
## EDITING COLLECTIONS (#120 F1, docs/node-tree-and-stacks.md §2.4): every action is ONE
## gesture, so one Ctrl+Z undoes it. The journal write goes through the map's
## LevelPersistence (the map owns the journal; the outliner only asks). The tree itself is
## CollectionFold's, read back with LevelPersistence.collection_tree().
##
## MEMBERS are what a collection holds: "g:<group>" (a brush object — a shape, an operator
## group, a Make Real group, or one brush), "obj:<dot>" (a scene object), "a:<dot>" (a
## placed asset) and "c:<dot>" (a curve, §16.2). The selection becomes members by
## selection_members():
## a selected object -> "obj:<dot>"
## the selected curve -> "c:<curve dot>"
## the selected shape -> "g:<shape dot>"
## a brush with a group -> "g:<group>" (its whole object, Blender's rule: you link
## objects, not pieces of them)
## a LOOSE brush (group "")-> first made an object of its own: brush.regroup to its own
## durable key, so every piece a later cut makes follows it.
## A cut already targeting that key still finds it (a target
## resolves as a group first, dc_level_journal.h cut_targets).
const CollectionFold := preload("res://scenes/maps/collection_fold.gd")
## The selection as members, regrouping loose brushes INSIDE the caller's gesture.
static func selection_members(map: Node) -> Array:
var out: Array = []
var seen := {}
for d: Variant in map.selected_objects():
_add(out, seen, "obj:" + String(d))
var cd := String(map.get("_sel_curve"))
if cd != "":
_add(out, seen, "c:" + cd)
var sd := String(map.get("_sel_shape"))
if sd != "":
_add(out, seen, "g:" + sd)
var level: Node = map.get("level")
var persist: Node = map.get("_persist")
for b: Variant in map.get("_sel_brushes"):
var g := String(level.get_brush_group(int(b)))
# A PLACED ASSET's piece is a member as its PLACEMENT, "a:<dot>" (#120 L4): its
# synthetic groups are renamed by every move.
var pa := String(persist.instance_of(g)) if g != "" and persist != null else ""
if pa != "":
_add(out, seen, "a:" + pa)
continue
if g == "":
var key := String(level.get_brush_key(int(b)))
if key == "":
continue
persist.record("brush.regroup", {"brush_keys": [key], "group": key})
g = key
_add(out, seen, "g:" + g)
return out
static func _add(out: Array, seen: Dictionary, m: String) -> void:
if not seen.has(m):
seen[m] = true
out.append(m)
static func _persist(map: Node) -> Node:
if map.has_method("_ensure_persistence"):
map.call("_ensure_persistence")
return map.get("_persist")
## Ctrl+G: a new collection holding the selection (empty when nothing is selected), under
## `parent` ("" = Scene). Returns its dot.
##
## A META COLLECTION (user 2026-10-18: "select several groups and turn them into a new
## meta group that keeps the inheritance but now has another parent collection"): with
## collections selected (`cols`, the outliner's rows) the new one takes their place —
## under their common parent, or Scene when they have different parents — and they move
## INTO it with their whole subtrees. A selected collection inside another selected one
## rides along with it. The rest of the selection is linked in as usual, except members
## those collections already hold (the viewport selection is their brushes too).
static func create_from_selection(map: Node, name: String = "Collection", parent: String = "",
cols: Array = []) -> String:
var p := _persist(map)
var t: Dictionary = p.collection_tree()
var tops := topmost(t, cols)
if not tops.is_empty():
parent = String(t["cols"][tops[0]]["parent"])
for c: String in tops:
if String(t["cols"][c]["parent"]) != parent:
parent = ""
p.begin_gesture("New collection")
var members := selection_members(map)
if not tops.is_empty():
members = members.filter(func(m: String) -> bool:
return not CollectionFold.collections_of(t, m).any(func(c: String) -> bool: return _within(t, c, tops)))
var dot: String = p.record("col.create", {"name": name, "parent": parent})
for c: String in tops:
p.record("col.set", {"target": c, "parent": dot})
if not members.is_empty():
p.record("col.link", {"target": dot, "members": members})
p.end_gesture()
return dot
## `cols` without any whose ancestor is also in it (that one moves with its ancestor), in
## the order given; unknown dots dropped.
static func topmost(t: Dictionary, cols: Array) -> Array:
var out: Array = []
for c: Variant in cols:
var d := String(c)
if not (t["cols"] as Dictionary).has(d) or out.has(d):
continue
var at := String(t["cols"][d]["parent"])
var nested := false
while at != "" and (t["cols"] as Dictionary).has(at):
if cols.has(at):
nested = true
break
at = String(t["cols"][at]["parent"])
if not nested:
out.append(d)
return out
## Is collection `c` one of `tops` or inside one of them?
static func _within(t: Dictionary, c: String, tops: Array) -> bool:
var at := c
while at != "" and (t["cols"] as Dictionary).has(at):
if tops.has(at):
return true
at = String(t["cols"][at]["parent"])
return false
## An empty collection (the outliner's + Collection).
static func create_empty(map: Node, name: String = "Collection", parent: String = "") -> String:
var p := _persist(map)
p.begin_gesture("New collection")
var dot: String = p.record("col.create", {"name": name, "parent": parent})
p.end_gesture()
return dot
## Shift+M: also link the selection into `col`. Returns how many members.
static func link_selection(map: Node, col: String) -> int:
var p := _persist(map)
p.begin_gesture("Link to collection")
var members := selection_members(map)
if not members.is_empty():
p.record("col.link", {"target": col, "members": members})
p.end_gesture()
return members.size()
## M: move the selection into `col` — unlinked from every other collection first.
static func move_selection(map: Node, col: String) -> int:
var p := _persist(map)
p.begin_gesture("Move to collection")
var members := selection_members(map)
_move(p, col, members)
p.end_gesture()
return members.size()
## The same for named members (an outliner drag): one gesture.
static func move_members(map: Node, col: String, members: Array) -> int:
var p := _persist(map)
p.begin_gesture("Move to collection")
_move(p, col, members)
p.end_gesture()
return members.size()
## Ctrl+drag: link named members into `col` too.
static func link_members(map: Node, col: String, members: Array) -> int:
var p := _persist(map)
p.begin_gesture("Link to collection")
if not members.is_empty():
p.record("col.link", {"target": col, "members": members})
p.end_gesture()
return members.size()
static func _move(p: Node, col: String, members: Array) -> void:
if members.is_empty():
return
var t: Dictionary = p.collection_tree()
var from := {}
for m: String in members:
for c: String in CollectionFold.collections_of(t, m):
if c != col:
if not from.has(c):
from[c] = []
(from[c] as Array).append(m)
for c: String in from:
p.record("col.unlink", {"target": c, "members": from[c]})
p.record("col.link", {"target": col, "members": members})
## Ctrl+Alt+G: take the selection out of `col` (it stays in any other).
static func unlink_selection(map: Node, col: String) -> int:
var p := _persist(map)
p.begin_gesture("Remove from collection")
var members := selection_members(map)
if not members.is_empty():
p.record("col.unlink", {"target": col, "members": members})
p.end_gesture()
return members.size()
## Ctrl+J: JOIN the selected brush objects into the ACTIVE one (Blender's join): every
## brush of every selected object takes the active brush's group. A shape's pieces are
## refused — they are regenerated from the shape, so a group change on them would not
## stick (Make Real first). Returns how many brushes moved; -1 when refused.
static func join(map: Node) -> int:
var level: Node = map.get("level")
var sel: Array = map.get("_sel_brushes")
var active := int(map.get("_sel_brush"))
if sel.size() < 2 or active < 0:
print("[Collections] join: select two or more brushes, the target last (it is the active one)")
return -1
for b: Variant in sel:
if map.shape_of_brush(int(b)) != "":
print("[Collections] join: a shape's pieces cannot be joined — Make Real first")
return -1
var target := String(level.get_brush_group(active))
if target == "":
target = String(level.get_brush_key(active))
var keys: Array = []
var seen := {}
for b: Variant in sel:
var g := String(level.get_brush_group(int(b)))
var ids: PackedInt32Array = level.get_group_brush_ids(g) if g != "" else PackedInt32Array([int(b)])
for x: int in ids:
var k := String(level.get_brush_key(x))
if k != "" and not seen.has(k) and String(level.get_brush_group(x)) != target:
seen[k] = true
keys.append(k)
var p := _persist(map)
p.begin_gesture("Join")
if not keys.is_empty():
p.record("brush.regroup", {"brush_keys": keys, "group": target})
p.end_gesture()
return keys.size()
## P: SEPARATE the selected brushes into a new brush object, which stays in the
## collections its source was in (Blender keeps a separated object's collections).
## Shape pieces refused, as join. Returns the new group, or "".
static func separate(map: Node) -> String:
var level: Node = map.get("level")
var sel: Array = map.get("_sel_brushes")
if sel.is_empty():
print("[Collections] separate: select brushes first")
return ""
for b: Variant in sel:
if map.shape_of_brush(int(b)) != "":
print("[Collections] separate: a shape's pieces cannot be separated — Make Real first")
return ""
var p := _persist(map)
var t: Dictionary = p.collection_tree()
var keys: Array = []
var cols := {}
for b: Variant in sel:
keys.append(String(level.get_brush_key(int(b))))
var g := String(level.get_brush_group(int(b)))
if g != "":
for c: String in CollectionFold.collections_of(t, "g:" + g):
cols[c] = true
p.begin_gesture("Separate")
var group: String = p.reserve_dot()
p.record("brush.regroup", {"brush_keys": keys, "group": group})
for c: String in cols:
p.record("col.link", {"target": c, "members": ["g:" + group]})
p.end_gesture()
return group
static func rename(map: Node, col: String, name: String) -> void:
var p := _persist(map)
p.begin_gesture("Rename collection")
p.record("col.set", {"target": col, "name": name})
p.end_gesture()
static func reparent(map: Node, col: String, parent: String) -> void:
var p := _persist(map)
p.begin_gesture("Move collection")
p.record("col.set", {"target": col, "parent": parent})
p.end_gesture()
## X on a collection: it goes, its contents move to its parent (Blender relinks them).
## Delete Hierarchy (`hierarchy`): the subtree goes, and every object linked ONLY inside
## it is deleted with it; an object also linked elsewhere survives, as in Blender.
static func remove(map: Node, col: String, hierarchy: bool = false) -> void:
var p := _persist(map)
var t: Dictionary = p.collection_tree()
p.begin_gesture("Delete hierarchy" if hierarchy else "Delete collection")
if hierarchy:
var inside := {}
var stack: Array = [col]
while not stack.is_empty():
var c: String = stack.pop_back()
if not t["cols"].has(c):
continue
inside[c] = true
stack.append_array(t["cols"][c]["children"])
for m: String in CollectionFold.members_of(t, col, true):
var elsewhere := false
for c2: String in CollectionFold.collections_of(t, m):
if not inside.has(c2):
elsewhere = true
if not elsewhere:
_delete_member(map, p, m)
p.record("col.remove", {"target": col, "hierarchy": hierarchy})
p.end_gesture()
## Delete what a member names, inside the caller's gesture.
static func _delete_member(map: Node, p: Node, m: String) -> void:
if m.begins_with("obj:"):
p.record("object.remove", {"target": m.substr(4)})
return
if m.begins_with("c:"):
# A curve something still uses stays (§16.7 Q2). Members go in sorted order, so a
# cable ("g:") in the same subtree is removed AFTER this, and remove_shape then
# takes the curve with it once nothing else uses it.
var cdot := m.substr(2)
if (p.curve_states() as Dictionary).has(cdot) and (p.curve_users(cdot) as Array).is_empty():
p.record("curve.remove", {"target": cdot})
return
var g := m.substr(2)
if (p.live_shapes() as Dictionary).has(g):
p.remove_shape(g) # a cable takes its curve with it
return
var level: Node = map.get("level")
for b: int in (level.get_group_brush_ids(g) as PackedInt32Array):
p.record("brush.remove", {"brush_key": String(level.get_brush_key(b))})