extends Node
## GATE — THE Q MENU'S STACK OPERATIONS (#120 L2 §13.4–13.5, stack_ops.gd).
## Q1 Change cut: the rows offer the other kinds; picking one changes the op's kind
## Q2 Extract: target ∩ cutter (as BEFORE the cut) becomes ONE new hull shape in an
## "Extract" collection; the cut stays on (the hole is still there)
## Q3 Apply up to here (first of two cuts): the first is baked (op.apply), the target is
## plain brushes keeping its name, the second cut stays LIVE and still cuts
## Q4 ... one Ctrl+Z: the shape is live again with both cuts
## Q5 BoxCutter Y: the drawn shape ∩ the target becomes a new shape, nothing is cut
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_q_stack.tscn
const StackOps := preload("res://scenes/maps/stack_ops.gd")
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
gs.route("world:level:*", 1, "peer")
var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
get_tree().root.get_node("/root/GameMode").set_mode("build")
map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
add_child(map)
for i in 6:
await get_tree().process_frame
await _run()
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 19:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE q_stack: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _box(at: Vector3, size: Vector3) -> String:
var d: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)),
"size": [size.x, size.y, size.z], "material_id": 2, "level_id": 0})
persist.refold()
return d
func _cut(kind: String, cutter: String, target: String) -> String:
persist.begin_gesture("cut")
var d: String = persist.record("op.create", {"op": kind, "cutter": cutter, "targets": [target]})
persist.end_gesture()
persist.refold()
return d
func _count(group: String, p: Vector3) -> int:
var n := 0
for b: int in (level.get_group_brush_ids(group) as PackedInt32Array):
var d: Dictionary = level.get_brush(b)
if bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
continue
var pl: PackedFloat32Array = d["planes"]
var inside := true
for i in pl.size() / 4:
if Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).dot(p) > pl[i * 4 + 3] + 1.0e-4:
inside = false
break
if inside:
n += 1
return n
func _kind(op: String) -> String:
return String(LevelPersistence.OpFold.resolve_states(persist.records(false)).get(op, {}).get("kind", ""))
func _col_named(name: String) -> Array:
var out: Array = []
var t: Dictionary = persist.collection_tree()
for cd: String in (t.get("cols", {}) as Dictionary):
if String(t["cols"][cd]["name"]) == name:
out.append(cd)
return out
func _run() -> void:
persist = map.get("_persist")
level = map.get("level")
persist.refold()
# ---- Q1 change cut ----
var t1 := _box(Vector3(300, 0, 0), Vector3(4, 2, 4))
var c1 := _box(Vector3(301, -1, 1), Vector3(2, 4, 2))
var o1 := _cut("subtract", c1, t1)
var rows: Array = StackOps.change_rows(map, t1)
var texts: Array = rows.map(func(r: Array) -> String: return String(r[0]))
_ok("Q1 Change cut offers the other four kinds", rows.size() == 4 and not str(texts).contains("→ subtract"), texts)
for r: Array in rows:
if String(r[0]).ends_with("→ slice"):
(r[1] as Callable).call()
_ok("Q1 ... picking slice changes the op", _kind(o1) == "slice", _kind(o1))
# ---- Q2 extract ----
var t2 := _box(Vector3(310, 0, 0), Vector3(4, 2, 4))
var c2 := _box(Vector3(311, 1, 1), Vector3(2, 2, 2)) # dips 1 into the top
var o2 := _cut("subtract", c2, t2)
var made: Array = StackOps.extract(map, t2, o2)
_ok("Q2 Extract makes one new shape", made.size() == 1, made)
if made.size() == 1:
var lo := Vector3.INF
var hi := -Vector3.INF
for b: int in (level.get_group_brush_ids(String(made[0])) as PackedInt32Array):
for c: Vector3 in FaceKit.corners(level.get_brush(b)["planes"]):
lo = lo.min(c)
hi = hi.max(c)
_ok("Q2 ... it is target ∩ cutter: x 311..313, y 1..2, z 1..3",
lo.is_equal_approx(Vector3(311, 1, 1)) and hi.is_equal_approx(Vector3(313, 2, 3)), [lo, hi])
var ex := _col_named("Extract")
_ok("Q2 ... in an Extract collection", ex.size() >= 1 and (persist.collection_tree()["cols"][ex[-1]]["members"] as Array).has("g:" + String(made[0]) if made.size() == 1 else ""))
_ok("Q2 ... and the cut is still on (the hole)", _count(t2, Vector3(312, 1.5, 2)) == 0)
# ---- Q3 / Q4 apply up to here ----
var t3 := _box(Vector3(320, 0, 0), Vector3(6, 2, 4))
var ca := _box(Vector3(320.5, -1, 1), Vector3(1, 4, 2))
var cb := _box(Vector3(324.5, -1, 1), Vector3(1, 4, 2))
var oa := _cut("subtract", ca, t3)
var ob := _cut("subtract", cb, t3)
var n3: int = StackOps.apply_up_to(map, t3, oa)
var live: Dictionary = persist.live_shapes()
var states: Dictionary = LevelPersistence.OpFold.resolve_states(persist.records(false))
_ok("Q3 Apply up to the first cut: the target is baked (no longer a live shape)", n3 > 0 and not live.has(t3), n3)
_ok("Q3 ... the first cut is applied, the second is not", bool(states[oa]["applied"]) and not bool(states[ob]["applied"]))
_ok("Q3 ... both holes are there (the second cut still cuts the baked pieces)",
_count(t3, Vector3(321, 1, 2)) == 0 and _count(t3, Vector3(325, 1, 2)) == 0
and _count(t3, Vector3(323, 1, 2)) == 1, [_count(t3, Vector3(321, 1, 2)), _count(t3, Vector3(325, 1, 2)), _count(t3, Vector3(323, 1, 2))])
persist.undo_last()
persist.refold()
_ok("Q4 one Ctrl+Z: the shape is live again, both cuts live", (persist.live_shapes() as Dictionary).has(t3)
and not bool(LevelPersistence.OpFold.resolve_states(persist.records(false))[oa]["applied"]))
# ---- Q6 the Q menu and the keys ----
map.select_shape(t2)
await get_tree().process_frame
var acts: Array = []
for sec: Array in (map.q_sections() as Array):
for row: Array in (sec[1] as Array):
acts.append(String(row[0]))
_ok("Q6 Q on a shape with cuts offers Change cut, Extract, Apply up to here",
acts.has("builder.change_cut") and acts.has("builder.extract_cut") and acts.has("builder.apply_up_to"), acts)
var ax := InputEventKey.new()
ax.keycode = KEY_X
ax.physical_keycode = KEY_X
ax.alt_pressed = true
ax.pressed = true
get_viewport().push_input(ax)
await get_tree().process_frame
var q: Node = map.get("_q_menu")
_ok("Q6 ... Alt+X opens Change cut", q.is_open())
(q.get("_menu") as PopupMenu).hide()
map.select_brushes(PackedInt32Array())
# ---- Q5 BoxCutter Y ----
map._ensure_ops()
var op: Node = map.get_node("BoxCutter")
var t5 := _box(Vector3(340, 0, 0), Vector3(4, 2, 4))
var nops := (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return String(r.get("kind", "")) == "op.create").size()
op.set_tool_setting("depth", 0)
op.place(Vector3(341, 2, 1), Vector3.UP, Vector3(341, 2, 1), Vector3(343, 2, 3), t5)
var k := InputEventKey.new()
k.keycode = KEY_Y
k.physical_keycode = KEY_Y
k.pressed = true
op.handle_key(k)
var nops2 := (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return String(r.get("kind", "")) == "op.create").size()
_ok("Q5 Y extracts the drawn shape ∩ the target, and cuts nothing", nops2 == nops and _count(t5, Vector3(342, 1, 2)) == 1
and (_col_named("Extract").size() >= 2), [nops2 - nops, _count(t5, Vector3(342, 1, 2)), _col_named("Extract").size()])
# ---- S: the stack scroll preview (§13.5, step 8) — LOCAL, never journaled ----
var t8 := _box(Vector3(360, 0, 0), Vector3(6, 2, 4))
var c8a := _box(Vector3(360.5, -1, 1), Vector3(1, 4, 2))
var c8b := _box(Vector3(364.5, -1, 1), Vector3(1, 4, 2))
_cut("subtract", c8a, t8)
_cut("subtract", c8b, t8)
map.select_shape(t8)
var nrec := (persist.records(false) as Array).size()
StackOps.scroll(map, -1)
_ok("S1 scroll back one: the first cut shows, the second does not",
_count(t8, Vector3(361, 1, 2)) == 0 and _count(t8, Vector3(365, 1, 2)) == 1)
StackOps.scroll(map, -1)
_ok("S2 back again: no cuts show", _count(t8, Vector3(361, 1, 2)) == 1 and _count(t8, Vector3(365, 1, 2)) == 1)
_ok("S3 nothing was journaled", (persist.records(false) as Array).size() == nrec, (persist.records(false) as Array).size() - nrec)
StackOps.scroll(map, 1)
StackOps.scroll(map, 1)
_ok("S4 forward twice: both cuts again", _count(t8, Vector3(361, 1, 2)) == 0 and _count(t8, Vector3(365, 1, 2)) == 0)
StackOps.scroll(map, -1)
map.select_brushes(PackedInt32Array())
await get_tree().process_frame
_ok("S5 a selection change clears the preview", _count(t8, Vector3(365, 1, 2)) == 0
and (not persist.has_meta("preview_off") or (persist.get_meta("preview_off") as Dictionary).is_empty()))
map.select_shape(t8)
await get_tree().process_frame
var kb := InputEventKey.new()
kb.keycode = KEY_BRACKETLEFT
kb.physical_keycode = KEY_BRACKETLEFT
kb.ctrl_pressed = true
kb.alt_pressed = true
kb.pressed = true
get_viewport().push_input(kb)
await get_tree().process_frame
_ok("S6 Ctrl+Alt+[ scrolls the selected shape's stack back", _count(t8, Vector3(365, 1, 2)) == 1)
StackOps.scroll_clear(map)