OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 5c2eca2

#120 L2 step 7: Change cut, Extract, Apply up to here (§13.4–13.5)
StackOps (static, like CollectionOps), each one gesture: - change_rows: Change cut ▸ the other kinds (set_op). - extract: the target as it was BEFORE the cut (op off, refold, back on, in the gesture) ∩ its cutter -> new hull shapes in an "Extract" collection. - apply_up_to: bake a shape target with only the ops up to the chosen one live; the baked pieces keep the TARGET's name so the later ops go on cutting them; the earlier ops are applied, unused cutters removed. BoxCutter Y: the drawn shape ∩ the targets -> new shapes, nothing cut. gate_q_stack (11). Q-menu rows and keys follow. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5c2eca286ff57392f59172c40461bc749b280eb4
by plankatron <fleeky@gmail.com> · 2026-10-19T01:20:34.000Z

Changed files (4) +405 -0

scenes/maps/operators/box_cutter_operator.gd +31 -0
……@@ -228,6 +228,34 @@ func _open_menu() -> void:
228228 q.open_choices("BoxCutter", rows, map.get_viewport().get_mouse_position())
229229
230230
231## Y (BoxCutter's extract): the drawn shape ∩ the targets becomes new hull shapes (an
232## "Extract" collection), and NOTHING is cut — no cutter, no op. One gesture.
233func _extract_now() -> void:
234 var fs := _frame()
235 if fs.is_empty() or _targets().is_empty():
236 print("[boxcutter] Y: draw a footprint on a target first")
237 return
238 var prism: Object = ParamShapes.get_type("prism")
239 var params: Dictionary = prism.resolve({"poly": fs[2], "taper": _taper, "shear_x": _shear_x, "shear_z": _shear_z})
240 var cutters: Array = []
241 for pc: Dictionary in (prism.local_pieces(fs[1], params) as Array):
242 cutters.append(ShapeFold.transform_planes(pc["planes"], fs[0]))
243 map._ensure_persistence()
244 map._persist.begin_gesture("BoxCutter extract")
245 var made: Array[String] = StackOps.extract_planes(map, _targets(), cutters)
246 map._persist.end_gesture()
247 map._persist.refold()
248 print("[boxcutter] extracted %d shape(s)" % made.size())
249 ModalControls.summary("BoxCutter extract · %d shape(s)" % made.size())
250 _phase = Phase.AIM
251 _frozen = false
252 _points.clear()
253
254
255const ShapeFold := preload("res://scenes/maps/shapes/param/shape_fold.gd")
256const StackOps := preload("res://scenes/maps/stack_ops.gd")
257
258
231259func _set_wedge(x: float, z: float) -> void:
232260 _shear_x = x
233261 _shear_z = z
……@@ -376,6 +404,9 @@ func handle_key(event: InputEvent) -> bool:
376404 KEY_L:
377405 _live = not _live
378406 return true
407 KEY_Y:
408 _extract_now()
409 return true
379410 KEY_X:
380411 _mode = (_mode + 1) % MODES.size()
381412 return true
scenes/maps/stack_ops.gd +204 -0
……@@ -0,0 +1,204 @@
1extends RefCounted
2## THE Q MENU'S STACK OPERATIONS (#120 L2, docs/node-tree-and-stacks.md §13.4–13.5):
3## Change cut, Extract, Apply up to here. Static, taking the map, like CollectionOps; each
4## is ONE gesture, so one Ctrl+Z takes it back.
5##
6## Two of them need the target AS IT WAS before some of its cuts. They get it the journal's
7## way, inside their own gesture: switch those ops off (op.set), refold, read the pieces,
8## and switch them back on. The net state is the ops on again; the off / on pair rides in
9## the same gesture, so nothing in between is ever seen by a peer as a separate step.
10
11const OpFold := preload("res://scenes/maps/op_fold.gd")
12
13
14## Change cut ▸: one row per (op on `subject`, kind it is not): picking it is set_op.
15static func change_rows(map: Node, subject: String) -> Array:
16 var rows: Array = []
17 for o: Dictionary in (map.op_stack(subject) as Array):
18 for k: String in OpFold.CUT_OPS:
19 if k == String(o["kind"]):
20 continue
21 rows.append(["%s → %s" % [_label(map, o), k], _set_kind.bind(map, String(o["dot"]), k)])
22 return rows
23
24
25static func _set_kind(map: Node, dot: String, kind: String) -> void:
26 map.set_op(dot, {"op": kind})
27
28
29static func _label(map: Node, o: Dictionary) -> String:
30 return "%s %s" % [String(o["kind"]), String(o["dot"]).get_slice(":", 1)]
31
32
33## Rows naming each op on `subject`, picking runs `fn(map, subject, op_dot)`.
34static func op_rows(map: Node, subject: String, fn: Callable) -> Array:
35 var rows: Array = []
36 for o: Dictionary in (map.op_stack(subject) as Array):
37 rows.append([_label(map, o), fn.bind(map, subject, String(o["dot"]))])
38 return rows
39
40
41# ---- EXTRACT -------------------------------------------------------------------------
42
43## A world plane set as a HULL shape.create payload, framed on the world axes at its
44## bounds' low corner (make_parametric's mapping, with an identity basis).
45static func hull_payload(planes: PackedFloat32Array, material: int, level_id: int) -> Dictionary:
46 var cs := FaceKit.corners(planes)
47 var lo := Vector3.INF
48 var hi := -Vector3.INF
49 for c: Vector3 in cs:
50 lo = lo.min(c)
51 hi = hi.max(c)
52 var size := (hi - lo).max(Vector3.ONE * 1.0e-4)
53 var unit: Array = []
54 for i in planes.size() / 4:
55 var n := Vector3(planes[i * 4], planes[i * 4 + 1], planes[i * 4 + 2])
56 var m := n * size
57 var dd := planes[i * 4 + 3] - n.dot(lo)
58 var l := maxf(m.length(), 1.0e-12)
59 unit.append_array([m.x / l, m.y / l, m.z / l, dd / l])
60 return {"type": "hull", "gv": 1, "params": {"planes": unit, "face_tex": []},
61 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, lo)), "size": [size.x, size.y, size.z],
62 "material_id": material, "level_id": level_id, "tex": {}}
63
64
65static func _has_volume(planes: PackedFloat32Array) -> bool:
66 var cs := FaceKit.corners(planes)
67 if cs.size() < 4:
68 return false
69 var lo := cs[0]
70 var hi := cs[0]
71 for v: Vector3 in cs:
72 lo = lo.min(v)
73 hi = hi.max(v)
74 var e := hi - lo
75 return e.x > 1.0e-3 and e.y > 1.0e-3 and e.z > 1.0e-3
76
77
78## The SOLID pieces of `groups` in the level now: [{planes, material, level_id}].
79static func solids_of(map: Node, groups: Array) -> Array:
80 var level: Node = map.get("level")
81 var out: Array = []
82 for g: Variant in groups:
83 for b: int in (level.get_group_brush_ids(String(g)) as PackedInt32Array):
84 var d: Dictionary = level.get_brush(b)
85 if bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
86 continue
87 out.append({"planes": d["planes"], "material": int(d.get("material_id", 0)),
88 "level_id": int(d.get("level_id", 0))})
89 return out
90
91
92## Every target piece ∩ cutter piece with volume, as new HULL shapes in a new "Extract"
93## collection. INSIDE the caller's gesture. Returns the new shape dots.
94static func extract_planes(map: Node, targets: Array, cutters: Array) -> Array[String]:
95 var p: Node = map.get("_persist")
96 var made: Array[String] = []
97 for t: Dictionary in solids_of(map, targets):
98 for c: Variant in cutters:
99 var pl: PackedFloat32Array = (t["planes"] as PackedFloat32Array) + (c as PackedFloat32Array)
100 if not _has_volume(pl):
101 continue
102 var sd: String = p.record("shape.create", hull_payload(pl, int(t["material"]), int(t["level_id"])))
103 if sd != "":
104 made.append(sd)
105 if not made.is_empty():
106 var col: String = p.record("col.create", {"name": "Extract", "parent": ""})
107 if col != "":
108 var members: Array = []
109 for sd2: String in made:
110 members.append("g:" + sd2)
111 p.record("col.link", {"target": col, "members": members})
112 return made
113
114
115## Extract ▸ <op>: the target as it was BEFORE that cut, ∩ its cutter, as new shapes.
116static func extract(map: Node, _subject: String, op_dot: String) -> Array[String]:
117 map._ensure_persistence()
118 var p: Node = map.get("_persist")
119 var st: Dictionary = OpFold.resolve_states(p.records(false)).get(op_dot, {})
120 if st.is_empty():
121 return []
122 var cutters: Array = []
123 for b: int in (map.get("level").get_group_brush_ids(String(st["cutter"])) as PackedInt32Array):
124 var d: Dictionary = map.get("level").get_brush(b)
125 if bool(d.get("cutter", false)):
126 cutters.append(d["planes"])
127 var was_on := bool(st.get("on", true))
128 p.begin_gesture("Extract")
129 if was_on:
130 p.record("op.set", {"target": op_dot, "on": false})
131 p.refold()
132 var made := extract_planes(map, Array(st["targets"]), cutters)
133 if was_on:
134 p.record("op.set", {"target": op_dot, "on": true})
135 p.end_gesture()
136 p.refold()
137 print("[stack] extracted %d shape(s) from %s" % [made.size(), op_dot])
138 return made
139
140
141# ---- APPLY UP TO HERE ------------------------------------------------------------------
142
143## Apply up to here ▸ <op> on the SHAPE `target`: bake it as folded with only the ops up to
144## and including `op_dot` live, and retire those (op.apply; a cutter no other live op uses
145## goes too). The baked pieces keep the TARGET's own name as their group, so the LATER ops
146## — which name it — go on cutting them. Returns the piece count (0 = refused).
147static func apply_up_to(map: Node, target: String, op_dot: String) -> int:
148 map._ensure_persistence()
149 var p: Node = map.get("_persist")
150 if not (p.live_shapes() as Dictionary).has(target):
151 print("[stack] apply up to here: %s is not a live shape (Make Real works on shapes)" % target)
152 return 0
153 var ops: Array = OpFold.existing_ops(p.records(false))
154 var mine: Array = [] # this target's ops, in stack order
155 for s: Dictionary in ops:
156 if (s["targets"] as PackedStringArray).has(target):
157 mine.append(s)
158 var k := -1
159 for i in mine.size():
160 if String(mine[i]["dot"]) == op_dot:
161 k = i
162 if k < 0:
163 return 0
164 var later_on: Array = []
165 for i2 in range(k + 1, mine.size()):
166 if bool(mine[i2].get("on", true)):
167 later_on.append(String(mine[i2]["dot"]))
168 var level: Node = map.get("level")
169 p.begin_gesture("Apply up to here")
170 for d: String in later_on:
171 p.record("op.set", {"target": d, "on": false})
172 p.refold()
173 var n := 0
174 for id: int in (level.get_group_brush_ids(target) as PackedInt32Array):
175 var bd: Dictionary = level.get_brush(id)
176 if bool(bd.get("cutter", false)):
177 continue
178 var payload: Dictionary = p._brush_payload(bd)
179 payload["group"] = target # the later ops' target name
180 if p.record("brush.create", payload) != "":
181 n += 1
182 p.record("shape.detach", {"target": target})
183 var retired := {}
184 for i3 in range(0, k + 1):
185 var s3: Dictionary = mine[i3]
186 if (s3["targets"] as PackedStringArray).size() == 1:
187 p.record("op.apply", {"target": s3["dot"]})
188 retired[s3["dot"]] = true
189 for s4: Dictionary in ops:
190 var c := String(s4["cutter"])
191 if not retired.has(s4["dot"]) or c == "":
192 continue
193 var used := false
194 for s5: Dictionary in ops:
195 if not retired.has(s5["dot"]) and String(s5["cutter"]) == c:
196 used = true
197 if not used:
198 p.record("shape.remove", {"target": c})
199 for d2: String in later_on:
200 p.record("op.set", {"target": d2, "on": true})
201 p.end_gesture()
202 p.refold()
203 print("[stack] applied %d cut(s) of %s: %d piece(s); %d later cut(s) stay live" % [retired.size(), target, n, later_on.size()])
204 return n
scenes/test/gate_q_stack.gd +164 -0
……@@ -0,0 +1,164 @@
1extends Node
2## GATE — THE Q MENU'S STACK OPERATIONS (#120 L2 §13.4–13.5, stack_ops.gd).
3## Q1 Change cut: the rows offer the other kinds; picking one changes the op's kind
4## Q2 Extract: target ∩ cutter (as BEFORE the cut) becomes ONE new hull shape in an
5## "Extract" collection; the cut stays on (the hole is still there)
6## Q3 Apply up to here (first of two cuts): the first is baked (op.apply), the target is
7## plain brushes keeping its name, the second cut stays LIVE and still cuts
8## Q4 ... one Ctrl+Z: the shape is live again with both cuts
9## Q5 BoxCutter Y: the drawn shape ∩ the target becomes a new shape, nothing is cut
10##
11## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_q_stack.tscn
12
13const StackOps := preload("res://scenes/maps/stack_ops.gd")
14
15var fails := 0
16var checks := 0
17var map: Node3D
18var persist: Node
19var level: Node
20
21
22func _ok(what: String, cond: bool, note: Variant = "") -> void:
23 checks += 1
24 if not cond:
25 fails += 1
26 print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
27
28
29func _ready() -> void:
30 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
31 gs.route("world:level:*", 1, "peer")
32 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
33 if keet != null:
34 keet.current_member_id = "gate-author"
35 get_tree().root.get_node("/root/GameMode").set_mode("build")
36 map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
37 add_child(map)
38 for i in 6:
39 await get_tree().process_frame
40 await _run()
41 get_tree().root.get_node("/root/GameMode").set_mode("play")
42 if checks < 11:
43 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
44 fails += 1
45 print("\nGATE q_stack: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
46 get_tree().quit(0 if fails == 0 else 1)
47
48
49func _box(at: Vector3, size: Vector3) -> String:
50 var d: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
51 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)),
52 "size": [size.x, size.y, size.z], "material_id": 2, "level_id": 0})
53 persist.refold()
54 return d
55
56
57func _cut(kind: String, cutter: String, target: String) -> String:
58 persist.begin_gesture("cut")
59 var d: String = persist.record("op.create", {"op": kind, "cutter": cutter, "targets": [target]})
60 persist.end_gesture()
61 persist.refold()
62 return d
63
64
65func _count(group: String, p: Vector3) -> int:
66 var n := 0
67 for b: int in (level.get_group_brush_ids(group) as PackedInt32Array):
68 var d: Dictionary = level.get_brush(b)
69 if bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
70 continue
71 var pl: PackedFloat32Array = d["planes"]
72 var inside := true
73 for i in pl.size() / 4:
74 if Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).dot(p) > pl[i * 4 + 3] + 1.0e-4:
75 inside = false
76 break
77 if inside:
78 n += 1
79 return n
80
81
82func _kind(op: String) -> String:
83 return String(LevelPersistence.OpFold.resolve_states(persist.records(false)).get(op, {}).get("kind", ""))
84
85
86func _col_named(name: String) -> Array:
87 var out: Array = []
88 var t: Dictionary = persist.collection_tree()
89 for cd: String in (t.get("cols", {}) as Dictionary):
90 if String(t["cols"][cd]["name"]) == name:
91 out.append(cd)
92 return out
93
94
95func _run() -> void:
96 persist = map.get("_persist")
97 level = map.get("level")
98 persist.refold()
99
100 # ---- Q1 change cut ----
101 var t1 := _box(Vector3(300, 0, 0), Vector3(4, 2, 4))
102 var c1 := _box(Vector3(301, -1, 1), Vector3(2, 4, 2))
103 var o1 := _cut("subtract", c1, t1)
104 var rows: Array = StackOps.change_rows(map, t1)
105 var texts: Array = rows.map(func(r: Array) -> String: return String(r[0]))
106 _ok("Q1 Change cut offers the other four kinds", rows.size() == 4 and not str(texts).contains("→ subtract"), texts)
107 for r: Array in rows:
108 if String(r[0]).ends_with("→ slice"):
109 (r[1] as Callable).call()
110 _ok("Q1 ... picking slice changes the op", _kind(o1) == "slice", _kind(o1))
111
112 # ---- Q2 extract ----
113 var t2 := _box(Vector3(310, 0, 0), Vector3(4, 2, 4))
114 var c2 := _box(Vector3(311, 1, 1), Vector3(2, 2, 2)) # dips 1 into the top
115 var o2 := _cut("subtract", c2, t2)
116 var made: Array = StackOps.extract(map, t2, o2)
117 _ok("Q2 Extract makes one new shape", made.size() == 1, made)
118 if made.size() == 1:
119 var lo := Vector3.INF
120 var hi := -Vector3.INF
121 for b: int in (level.get_group_brush_ids(String(made[0])) as PackedInt32Array):
122 for c: Vector3 in FaceKit.corners(level.get_brush(b)["planes"]):
123 lo = lo.min(c)
124 hi = hi.max(c)
125 _ok("Q2 ... it is target ∩ cutter: x 311..313, y 1..2, z 1..3",
126 lo.is_equal_approx(Vector3(311, 1, 1)) and hi.is_equal_approx(Vector3(313, 2, 3)), [lo, hi])
127 var ex := _col_named("Extract")
128 _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 ""))
129 _ok("Q2 ... and the cut is still on (the hole)", _count(t2, Vector3(312, 1.5, 2)) == 0)
130
131 # ---- Q3 / Q4 apply up to here ----
132 var t3 := _box(Vector3(320, 0, 0), Vector3(6, 2, 4))
133 var ca := _box(Vector3(320.5, -1, 1), Vector3(1, 4, 2))
134 var cb := _box(Vector3(324.5, -1, 1), Vector3(1, 4, 2))
135 var oa := _cut("subtract", ca, t3)
136 var ob := _cut("subtract", cb, t3)
137 var n3: int = StackOps.apply_up_to(map, t3, oa)
138 var live: Dictionary = persist.live_shapes()
139 var states: Dictionary = LevelPersistence.OpFold.resolve_states(persist.records(false))
140 _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)
141 _ok("Q3 ... the first cut is applied, the second is not", bool(states[oa]["applied"]) and not bool(states[ob]["applied"]))
142 _ok("Q3 ... both holes are there (the second cut still cuts the baked pieces)",
143 _count(t3, Vector3(321, 1, 2)) == 0 and _count(t3, Vector3(325, 1, 2)) == 0
144 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))])
145 persist.undo_last()
146 persist.refold()
147 _ok("Q4 one Ctrl+Z: the shape is live again, both cuts live", (persist.live_shapes() as Dictionary).has(t3)
148 and not bool(LevelPersistence.OpFold.resolve_states(persist.records(false))[oa]["applied"]))
149
150 # ---- Q5 BoxCutter Y ----
151 map._ensure_ops()
152 var op: Node = map.get_node("BoxCutter")
153 var t5 := _box(Vector3(340, 0, 0), Vector3(4, 2, 4))
154 var nops := (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return String(r.get("kind", "")) == "op.create").size()
155 op.set_tool_setting("depth", 0)
156 op.place(Vector3(341, 2, 1), Vector3.UP, Vector3(341, 2, 1), Vector3(343, 2, 3), t5)
157 var k := InputEventKey.new()
158 k.keycode = KEY_Y
159 k.physical_keycode = KEY_Y
160 k.pressed = true
161 op.handle_key(k)
162 var nops2 := (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return String(r.get("kind", "")) == "op.create").size()
163 _ok("Q5 Y extracts the drawn shape ∩ the target, and cuts nothing", nops2 == nops and _count(t5, Vector3(342, 1, 2)) == 1
164 and (_col_named("Extract").size() >= 2), [nops2 - nops, _count(t5, Vector3(342, 1, 2)), _col_named("Extract").size()])
scenes/test/gate_q_stack.tscn +6 -0
……@@ -0,0 +1,6 @@
1[gd_scene load_steps=2 format=3]
2
3[ext_resource type="Script" path="res://scenes/test/gate_q_stack.gd" id="1"]
4
5[node name="QStackGate" type="Node"]
6script = ExtResource("1")