extends RefCounted
## THE Q MENU'S STACK OPERATIONS (#120 L2, docs/node-tree-and-stacks.md §13.4–13.5):
## Change cut, Extract, Apply up to here. Static, taking the map, like CollectionOps; each
## is ONE gesture, so one Ctrl+Z takes it back.
##
## Two of them need the target AS IT WAS before some of its cuts. They get it the journal's
## way, inside their own gesture: switch those ops off (op.set), refold, read the pieces,
## and switch them back on. The net state is the ops on again; the off / on pair rides in
## the same gesture, so nothing in between is ever seen by a peer as a separate step.
const OpFold := preload("res://scenes/maps/op_fold.gd")
## Change cut ▸: one row per (op on `subject`, kind it is not): picking it is set_op.
static func change_rows(map: Node, subject: String) -> Array:
var rows: Array = []
for o: Dictionary in (map.op_stack(subject) as Array):
for k: String in OpFold.CUT_OPS:
if k == String(o["kind"]):
continue
rows.append(["%s → %s" % [_label(map, o), k], _set_kind.bind(map, String(o["dot"]), k)])
return rows
static func _set_kind(map: Node, dot: String, kind: String) -> void:
map.set_op(dot, {"op": kind})
static func _label(map: Node, o: Dictionary) -> String:
return "%s %s" % [String(o["kind"]), String(o["dot"]).get_slice(":", 1)]
## Rows naming each op on `subject`, picking runs `fn(map, subject, op_dot)`.
static func op_rows(map: Node, subject: String, fn: Callable) -> Array:
var rows: Array = []
for o: Dictionary in (map.op_stack(subject) as Array):
rows.append([_label(map, o), fn.bind(map, subject, String(o["dot"]))])
return rows
# ---- EXTRACT -------------------------------------------------------------------------
## A world plane set as a HULL shape.create payload, framed on the world axes at its
## bounds' low corner (make_parametric's mapping, with an identity basis).
static func hull_payload(planes: PackedFloat32Array, material: int, level_id: int) -> Dictionary:
var cs := FaceKit.corners(planes)
var lo := Vector3.INF
var hi := -Vector3.INF
for c: Vector3 in cs:
lo = lo.min(c)
hi = hi.max(c)
var size := (hi - lo).max(Vector3.ONE * 1.0e-4)
var unit: Array = []
for i in planes.size() / 4:
var n := Vector3(planes[i * 4], planes[i * 4 + 1], planes[i * 4 + 2])
var m := n * size
var dd := planes[i * 4 + 3] - n.dot(lo)
var l := maxf(m.length(), 1.0e-12)
unit.append_array([m.x / l, m.y / l, m.z / l, dd / l])
return {"type": "hull", "gv": 1, "params": {"planes": unit, "face_tex": []},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, lo)), "size": [size.x, size.y, size.z],
"material_id": material, "level_id": level_id, "tex": {}}
static func _has_volume(planes: PackedFloat32Array) -> bool:
var cs := FaceKit.corners(planes)
if cs.size() < 4:
return false
var lo := cs[0]
var hi := cs[0]
for v: Vector3 in cs:
lo = lo.min(v)
hi = hi.max(v)
var e := hi - lo
return e.x > 1.0e-3 and e.y > 1.0e-3 and e.z > 1.0e-3
## The SOLID pieces of `groups` in the level now: [{planes, material, level_id}].
static func solids_of(map: Node, groups: Array) -> Array:
var level: Node = map.get("level")
var out: Array = []
for g: Variant in groups:
for b: int in (level.get_group_brush_ids(String(g)) as PackedInt32Array):
var d: Dictionary = level.get_brush(b)
if bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
continue
out.append({"planes": d["planes"], "material": int(d.get("material_id", 0)),
"level_id": int(d.get("level_id", 0))})
return out
## Every target piece ∩ cutter piece with volume, as new HULL shapes in a new "Extract"
## collection. INSIDE the caller's gesture. Returns the new shape dots.
static func extract_planes(map: Node, targets: Array, cutters: Array) -> Array[String]:
var p: Node = map.get("_persist")
var made: Array[String] = []
for t: Dictionary in solids_of(map, targets):
for c: Variant in cutters:
var pl: PackedFloat32Array = (t["planes"] as PackedFloat32Array) + (c as PackedFloat32Array)
if not _has_volume(pl):
continue
var sd: String = p.record("shape.create", hull_payload(pl, int(t["material"]), int(t["level_id"])))
if sd != "":
made.append(sd)
if not made.is_empty():
var col: String = p.record("col.create", {"name": "Extract", "parent": ""})
if col != "":
var members: Array = []
for sd2: String in made:
members.append("g:" + sd2)
p.record("col.link", {"target": col, "members": members})
return made
## Extract ▸ <op>: the target as it was BEFORE that cut, ∩ its cutter, as new shapes.
static func extract(map: Node, _subject: String, op_dot: String) -> Array[String]:
map._ensure_persistence()
var p: Node = map.get("_persist")
var st: Dictionary = OpFold.resolve_states(p.records(false)).get(op_dot, {})
if st.is_empty():
return []
var cutters: Array = []
for b: int in (map.get("level").get_group_brush_ids(String(st["cutter"])) as PackedInt32Array):
var d: Dictionary = map.get("level").get_brush(b)
if bool(d.get("cutter", false)):
cutters.append(d["planes"])
var was_on := bool(st.get("on", true))
p.begin_gesture("Extract")
if was_on:
p.record("op.set", {"target": op_dot, "on": false})
p.refold()
var made := extract_planes(map, Array(st["targets"]), cutters)
if was_on:
p.record("op.set", {"target": op_dot, "on": true})
p.end_gesture()
p.refold()
print("[stack] extracted %d shape(s) from %s" % [made.size(), op_dot])
return made
# ---- STACK SCROLL (HardOps' modifier_scroll, as a VIEW) ---------------------------------
#
# Ctrl+Alt+[ / ] shows the subject as if only its first k cuts existed. LOCAL: the ops
# after row k go in the persistence node's "preview_off" meta, which the fold skips when
# it expands the cuts; nothing is journaled, so no peer ever sees it. A selection change
# or a tool starting clears it (scroll_clear / clear_if_moved).
## Step the preview of the selected subject's stack by `delta` rows (−1 = one cut fewer).
static func scroll(map: Node, delta: int) -> void:
map._ensure_persistence()
var p: Node = map.get("_persist")
var subject: String = map.stack_subject()
var ops: Array = map.op_stack(subject)
if subject == "" or ops.is_empty():
return
var st: Dictionary = map.get_meta("stack_scroll", {})
var k := ops.size() if String(st.get("subject", "")) != subject else int(st["k"])
k = clampi(k + delta, 0, ops.size())
var off := {}
for i in range(k, ops.size()):
off[String(ops[i]["dot"])] = true
map.set_meta("stack_scroll", {"subject": subject, "k": k})
p.set_preview_off(off)
p.refold()
if map.has_method("_refresh_brush_view"):
map._refresh_brush_view()
ModalControls.summary("Stack: %d of %d cut(s) shown%s" % [k, ops.size(), "" if k == ops.size() else " (preview)"])
## End the preview: every cut shows again.
static func scroll_clear(map: Node) -> void:
var p: Variant = map.get("_persist")
if not map.has_meta("stack_scroll") or p == null:
return
map.remove_meta("stack_scroll")
(p as Node).set_preview_off({})
(p as Node).refold()
if map.has_method("_refresh_brush_view"):
map._refresh_brush_view()
## The selection moved off the previewed subject: end the preview.
static func clear_if_moved(map: Node) -> void:
if map.has_meta("stack_scroll") and String((map.get_meta("stack_scroll") as Dictionary)["subject"]) != String(map.stack_subject()):
scroll_clear(map)
# ---- APPLY UP TO HERE ------------------------------------------------------------------
## Apply up to here ▸ <op> on the SHAPE `target`: bake it as folded with only the ops up to
## and including `op_dot` live, and retire those (op.apply; a cutter no other live op uses
## goes too). The baked pieces keep the TARGET's own name as their group, so the LATER ops
## — which name it — go on cutting them. Returns the piece count (0 = refused).
static func apply_up_to(map: Node, target: String, op_dot: String) -> int:
map._ensure_persistence()
var p: Node = map.get("_persist")
if not (p.live_shapes() as Dictionary).has(target):
print("[stack] apply up to here: %s is not a live shape (Make Real works on shapes)" % target)
return 0
var ops: Array = OpFold.existing_ops(p.records(false))
var mine: Array = [] # this target's ops, in stack order
for s: Dictionary in ops:
if (s["targets"] as PackedStringArray).has(target):
mine.append(s)
var k := -1
for i in mine.size():
if String(mine[i]["dot"]) == op_dot:
k = i
if k < 0:
return 0
var later_on: Array = []
for i2 in range(k + 1, mine.size()):
if bool(mine[i2].get("on", true)):
later_on.append(String(mine[i2]["dot"]))
var level: Node = map.get("level")
p.begin_gesture("Apply up to here")
for d: String in later_on:
p.record("op.set", {"target": d, "on": false})
p.refold()
var n := 0
for id: int in (level.get_group_brush_ids(target) as PackedInt32Array):
var bd: Dictionary = level.get_brush(id)
if bool(bd.get("cutter", false)):
continue
var payload: Dictionary = p._brush_payload(bd)
payload["group"] = target # the later ops' target name
if p.record("brush.create", payload) != "":
n += 1
p.record("shape.detach", {"target": target})
var retired := {}
for i3 in range(0, k + 1):
var s3: Dictionary = mine[i3]
if (s3["targets"] as PackedStringArray).size() == 1:
p.record("op.apply", {"target": s3["dot"]})
retired[s3["dot"]] = true
for s4: Dictionary in ops:
var c := String(s4["cutter"])
if not retired.has(s4["dot"]) or c == "":
continue
var used := false
for s5: Dictionary in ops:
if not retired.has(s5["dot"]) and String(s5["cutter"]) == c:
used = true
if not used:
p.record("shape.remove", {"target": c})
for d2: String in later_on:
p.record("op.set", {"target": d2, "on": true})
p.end_gesture()
p.refold()
print("[stack] applied %d cut(s) of %s: %d piece(s); %d later cut(s) stay live" % [retired.size(), target, n, later_on.size()])
return n