OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / maps / stack_ops.gd

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