OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / collection_ops.gd

339 lines · gd ref:
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))})