OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 6eb66be

#120 L2 step 3: the BoxCutter tool, rect on a face (§13.2–13.4)
BoxCutterOperator: aim at a face (plane + target), P sets the first corner, the aim drags the other on the plane, snapped to the lattice. BoxCutter's keys while up: X cycles slice/intersect/inset/outset/cut, I inset, E a set depth ([ ] steps it), Space through + commit. The cutter is a prism one grid step above the surface reaching through (CutDepth) or the set depth; commit is ONE gesture (shape.create prism + op.create). gate_box_cutter (14). Alt+W binding follows. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6eb66be24e439e56698ab2304edd3190e6cd803e
by plankatron <fleeky@gmail.com> · 2026-10-19T00:44:51.000Z

Changed files (4) +499 -0

scenes/maps/map_dc_level.gd +1 -0
……@@ -3021,6 +3021,7 @@ func _ensure_ops() -> OperatorStack:
30213021 _grab_op = _make_op(ElementOperator.new(), "Grab")
30223022 _extrude = _make_op(ExtrudeOperator.new(), "Extrude")
30233023 _box = _make_op(CreateBoxOperator.new(), "CreateBox")
3024 _make_op(BoxCutterOperator.new(), "BoxCutter") # #120 L2 §13: found by name ("BoxCutter")
30243025 # The HUD shows which operator is live and what it is set to, so a modal tool is
30253026 # never a mode you are in without being told.
30263027 _ops.changed.connect(func(_op: LevelOperator) -> void: _update_op_hud())
scenes/maps/operators/box_cutter_operator.gd +332 -0
……@@ -0,0 +1,332 @@
1extends LevelOperator
2class_name BoxCutterOperator
3## BOXCUTTER — draw a cutter on the surface you aim at, and it cuts (#120 L2,
4## docs/node-tree-and-stacks.md §13.2–13.4).
5##
6## The mouse is the camera in every builder tool, so drawing is AIMING, as in the create
7## and curve tools: aim at a face (it picks the plane and the target), P sets the first
8## corner, the aim drags the other across that plane, snapped to the lattice.
9##
10## KEYS ARE BOXCUTTER'S (its manual; user 2026-10-18), and only while the tool is up:
11## P the first corner X mode: slice → intersect → inset → outset → cut
12## I inset E extrude: a set depth ([ ] steps it)
13## Space THROUGH (lazorcut), and commit
14## LMB / Enter commit · Esc / RMB cancel · H help
15##
16## THE CUTTER is a `prism` shape (§13.1): its frame's y points INTO the surface, its
17## bottom sits one grid step ABOVE the surface (so the face is opened cleanly, not left
18## coplanar), and it reaches the set depth below — or THROUGH the target (CutDepth,
19## measured now and stored). ONE gesture writes the shape and the live op, so one Ctrl+Z
20## takes the whole cut back.
21
22const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")
23const CutDepth := preload("res://scenes/maps/cut_depth.gd")
24
25## In X's cycle order, BoxCutter's: cut (the default), slice, intersect, inset; outset
26## (no BoxCutter key) after inset.
27const MODES := ["subtract", "slice", "intersect", "inset", "outset"]
28const _UNIT := [Vector3.RIGHT, Vector3.UP, Vector3.BACK]
29
30enum Phase { AIM, DRAW }
31
32var _phase := Phase.AIM
33var _mode := 0
34var _o := Vector3.ZERO # the drawn plane: a point on it, its normal, two axes in it
35var _n := Vector3.UP
36var _u := Vector3.BACK
37var _v := Vector3.RIGHT
38var _a := Vector3.ZERO # the first corner, on the plane
39var _b := Vector3.ZERO # the other corner
40var _target := "" # the group (or brush key) the cut goes into
41var _material := 0
42var _level_id := 0
43var _depth_steps := 0 # 0 = THROUGH
44var _valid := false
45var _frozen := false # placed by place() (gates, Adjust Last): the aim does not move it
46
47
48func op_name() -> String:
49 return "boxcutter"
50
51
52func preview_color() -> Color:
53 return Color(1.0, 0.25, 0.2, 0.45) # red: about to cut
54
55
56func mode() -> String:
57 return String(MODES[_mode])
58
59
60func setup_modal(m: ModalControls) -> void:
61 m.confirm_label = "cut"
62 m.table([["P", "first corner", func() -> String: return "set — aim for the other" if _phase == Phase.DRAW else "aim, then P"],
63 ["X / I", "mode", func() -> String: return mode()],
64 ["E / Space", "depth", func() -> String: return _depth_text()],
65 ["", "aim", func() -> String: return "" if _valid else "aim at a surface"]])
66 m.bind("depth", "depth", func() -> float: return float(_depth_steps), _set_depth,
67 1.0, func() -> String: return " (%s)" % _depth_text(), 1.0)
68
69
70func _set_depth(v: float) -> void:
71 _depth_steps = maxi(0, int(roundf(v)))
72 update_preview()
73
74
75func _depth_text() -> String:
76 return "through" if _depth_steps == 0 else fmt_units(_depth_steps * grid_units())
77
78
79func status() -> String:
80 return modal().status_text()
81
82
83func readout() -> String:
84 return "%s · %s" % [mode(), _depth_text()] if _valid else ""
85
86
87func on_activate() -> bool:
88 _phase = Phase.AIM
89 _frozen = false
90 update_preview()
91 if not _valid:
92 print("[boxcutter] aim at a surface to draw a cutter on")
93 return false
94 return true
95
96
97func on_deactivate() -> void:
98 _phase = Phase.AIM
99 _frozen = false
100 clear_preview()
101
102
103func handle_key(event: InputEvent) -> bool:
104 if not (event is InputEventKey) or not (event as InputEventKey).pressed:
105 return false
106 var k := event as InputEventKey
107 if k.ctrl_pressed or k.alt_pressed:
108 return false
109 match KeyRules.key_of(k):
110 KEY_P:
111 if _valid:
112 _a = _b if _phase == Phase.DRAW else _a
113 _phase = Phase.DRAW
114 update_preview()
115 return true
116 KEY_X:
117 _mode = (_mode + 1) % MODES.size()
118 return true
119 KEY_I:
120 _mode = MODES.find("inset")
121 return true
122 KEY_E:
123 # A set depth, starting from what THROUGH would be, so [ ] steps from there.
124 if _depth_steps == 0:
125 _depth_steps = maxi(1, roundi(_through() / grid_step()))
126 update_preview()
127 return true
128 KEY_SPACE:
129 _depth_steps = 0
130 confirm_via_stack()
131 return true
132 return modal().handle_key(event)
133
134
135## Place the drawn rect directly: the plane `o` / `n`, corners `a` and `b` on it, the cut
136## going into `target` — what Adjust Last and the gates use instead of aiming.
137func place(o: Vector3, n: Vector3, a: Vector3, b: Vector3, target: String) -> void:
138 _set_plane(o, n)
139 _a = a
140 _b = b
141 _target = target
142 _phase = Phase.DRAW
143 _valid = true
144 _frozen = true
145
146
147func _set_plane(o: Vector3, n: Vector3) -> void:
148 _o = o
149 _n = n.normalized()
150 var ax := 0
151 if absf(_n.y) >= absf(_n.x) and absf(_n.y) >= absf(_n.z):
152 ax = 1
153 elif absf(_n.z) > absf(_n.x):
154 ax = 2
155 var axial := absf(_n[ax]) > 0.999
156 if axial:
157 _u = _UNIT[(ax + 1) % 3]
158 else:
159 _u = (_n.cross(Vector3.UP) if absf(_n.y) < 0.99 else _n.cross(Vector3.RIGHT)).normalized()
160 _v = _n.cross(_u)
161
162
163## A point on the plane, its two in-plane coordinates on the map lattice (an axial plane;
164## a slanted one keeps the lattice of its own u / v from the plane's point).
165func _snap(p: Vector3) -> Vector3:
166 var out := p
167 for i in 3:
168 var e: Vector3 = _UNIT[i]
169 if absf(_n.dot(e)) > 0.999:
170 continue # along the normal: stay on the surface
171 if absf(_u.dot(e)) > 0.999 or absf(_v.dot(e)) > 0.999:
172 out[i] = map.snap_to_map_grid(p[i], e, grid_units(), map._map_center, map._aligned_offset)
173 return out
174
175
176func _aim_on_plane() -> Variant:
177 var ray: Dictionary = map._crosshair_ray()
178 if ray.is_empty():
179 return null
180 var dir: Vector3 = ray["dir"]
181 var den := _n.dot(dir)
182 if absf(den) < 1.0e-4:
183 return null
184 var t := _n.dot(_o - (ray["from"] as Vector3)) / den
185 return null if t < 0.0 else (ray["from"] as Vector3) + dir * t
186
187
188func update_preview() -> void:
189 clear_preview()
190 if not _frozen:
191 if _phase == Phase.AIM:
192 _valid = false
193 var hit: Dictionary = map._crosshair_hit()
194 if hit.is_empty():
195 return
196 var brush: int = map._brush_from_hit(hit)
197 if brush < 0:
198 return
199 var b: Dictionary = level.get_brush(brush)
200 _material = int(b.get("material_id", 0))
201 _level_id = int(b.get("level_id", 0))
202 var g := String(level.get_brush_group(brush))
203 _target = g if g != "" else String(level.get_brush_key(brush))
204 _set_plane(hit["position"], hit.get("normal", Vector3.UP))
205 _a = _snap(_o)
206 _b = _a + (_u + _v) * grid_step()
207 _valid = true
208 else:
209 var p: Variant = _aim_on_plane()
210 if p != null:
211 _b = _snap(p)
212 if _valid:
213 draw_lines(_edges())
214
215
216## The rect's corners on the plane, ordered round it: [lo-lo, hi-lo, hi-hi, lo-hi] in (u, v).
217func _rect() -> Array:
218 var ua := (_a - _o).dot(_u)
219 var ub := (_b - _o).dot(_u)
220 var va := (_a - _o).dot(_v)
221 var vb := (_b - _o).dot(_v)
222 var u0 := minf(ua, ub)
223 var u1 := maxf(ua, ub)
224 var v0 := minf(va, vb)
225 var v1 := maxf(va, vb)
226 return [_o + _u * u0 + _v * v0, _o + _u * u1 + _v * v0, _o + _u * u1 + _v * v1, _o + _u * u0 + _v * v1]
227
228
229## How far below the surface the cut reaches: the set depth, or THROUGH the target now.
230func _depth() -> float:
231 if _depth_steps > 0:
232 return float(_depth_steps) * grid_step()
233 var d := _through()
234 return d if d > 0.0 else grid_step()
235
236
237func _through() -> float:
238 var pieces: Array = []
239 for b: int in (level.get_group_brush_ids(_target) as PackedInt32Array):
240 var bd: Dictionary = level.get_brush(b)
241 if not bool(bd.get("clearance", false)) and not bool(bd.get("cutter", false)):
242 pieces.append(bd["planes"])
243 if pieces.is_empty():
244 var id: int = level.brush_by_key(_target) if level.has_method("brush_by_key") else -1
245 if id >= 0:
246 pieces.append(level.get_brush(id)["planes"])
247 return CutDepth.through(pieces, _o, _n, grid_step())
248
249
250## [frame, size] of the prism: x = u, y = INTO the surface, z = v (right-handed: u × −n = v);
251## the bottom one grid step above the surface, the size reaching the depth below it.
252func _frame() -> Array:
253 var r := _rect()
254 var lo: Vector3 = r[0]
255 var w := (r[1] as Vector3 - lo).length()
256 var l := (r[3] as Vector3 - lo).length()
257 var m := grid_step()
258 var f := Transform3D(Basis(_u, -_n, _v), lo + _n * m)
259 return [f, Vector3(w, _depth() + m, l)]
260
261
262func _edges() -> PackedVector3Array:
263 var out := PackedVector3Array()
264 var r := _rect()
265 var down := -_n * _depth()
266 for i in 4:
267 var p: Vector3 = r[i]
268 var q: Vector3 = r[(i + 1) % 4]
269 out.append_array(PackedVector3Array([p, q, p + down, q + down, p, p + down]))
270 return out
271
272
273func tool_settings() -> Array:
274 return [
275 {"id": "mode", "label": "Mode", "type": "enum", "value": _mode, "choices": MODES,
276 "tip": "X cycles, I = inset (BoxCutter's keys)."},
277 {"id": "depth", "label": "Depth", "type": "int", "value": _depth_steps, "min": 0, "step": 1,
278 "unit": "grid", "tip": "Below the surface, in grid steps; 0 = through (Space)."},
279 {"id": "cut", "label": "Cut", "type": "action", "tip": "Commit (LMB / Enter / Space)."},
280 ]
281
282
283func set_tool_setting(sid: String, value: Variant) -> bool:
284 match sid:
285 "mode":
286 _mode = posmod(int(value), MODES.size())
287 "depth":
288 _depth_steps = maxi(0, int(value))
289 "cut":
290 confirm_via_stack()
291 return true
292 _:
293 return false
294 update_preview()
295 return true
296
297
298func gesture_label() -> String:
299 return "BoxCutter %s" % mode()
300
301
302func confirm() -> bool:
303 if not _valid or _phase != Phase.DRAW or _target == "":
304 print("[boxcutter] draw a rect first: aim, P, aim")
305 return false
306 var fs := _frame()
307 var size: Vector3 = fs[1]
308 if size.x < 1.0e-3 or size.z < 1.0e-3:
309 print("[boxcutter] the rect has no area: aim away from the first corner")
310 return false
311 var prism: Object = ParamShapes.get_type("prism")
312 map._ensure_persistence()
313 map._persist.begin_gesture(gesture_label())
314 var dot: String = map._persist.record("shape.create", {"type": "prism", "gv": prism.GV,
315 "params": prism.resolve({}), "frame": SceneObjects.xform_to(fs[0]),
316 "size": [size.x, size.y, size.z], "material_id": _material, "level_id": _level_id})
317 var ok := dot != ""
318 if ok:
319 var rec := {"op": mode(), "cutter": dot, "targets": [_target]}
320 ok = map._persist.record("op.create", rec) != ""
321 map._persist.end_gesture()
322 if not ok:
323 print("[boxcutter] the journal refused the cut")
324 return false
325 map._persist.refold()
326 if map.has_method("_refresh_brush_view"):
327 map._refresh_brush_view()
328 print("[boxcutter] %s %s into %s, %s" % [mode(), dot, _target, _depth_text()])
329 ModalControls.summary("BoxCutter %s · %s" % [mode(), _depth_text()])
330 _phase = Phase.AIM
331 _frozen = false
332 return true
scenes/test/gate_box_cutter.gd +160 -0
……@@ -0,0 +1,160 @@
1extends Node
2## GATE — THE BOXCUTTER TOOL (#120 L2 §13.2–13.4; box_cutter_operator.gd). Step 3: a rect
3## drawn on a face, the modes, depth, one gesture.
4## B0 the map has the operator (Alt+W starts it; keymap_gate checks the binding)
5## B1 a rect on the top face, through (default): ONE gesture writes a prism shape.create
6## and an op.create subtract, the cutter's dot as the op's cutter
7## B2 ... the hole goes right through: air at mid-height and at the bottom, solid beside
8## B3 X once = slice (BoxCutter's X): the committed op is a slice
9## B4 I = inset: a pocket (default depth 0.25) — air near the top, solid below it
10## B5 a set depth (2 grid steps, E's value): air above it, solid below
11## B6 one Ctrl+Z removes the whole commit (shape and op): the target is whole again
12##
13## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_box_cutter.tscn
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 < 12:
43 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
44 fails += 1
45 print("\nGATE box_cutter: %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 _key(code: Key) -> InputEventKey:
50 var e := InputEventKey.new()
51 e.keycode = code
52 e.physical_keycode = code
53 e.pressed = true
54 return e
55
56
57func _box(at: Vector3, size: Vector3) -> String:
58 var d: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
59 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)),
60 "size": [size.x, size.y, size.z], "material_id": 2, "level_id": 0})
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 _last(kind: String) -> Dictionary:
83 var best := {}
84 for r: Dictionary in (persist.records(false) as Array):
85 if String(r.get("kind", "")) == kind and (best.is_empty() or int(r["seq"]) > int(best["seq"])):
86 best = r
87 return best
88
89
90func _n(kind: String) -> int:
91 # Live records only: an undone one stays in the journal, flagged.
92 return (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return String(r.get("kind", "")) == kind and not bool(r.get("undone", false))).size()
93
94
95## Draw the 2 x 2 rect (x 1..3, z 1..3) on top of a target box (x 0..4, y 0..2, z 0..4).
96func _draw(op: Node, t: String) -> void:
97 op.place(Vector3(1, 2, 1), Vector3.UP, Vector3(1, 2, 1), Vector3(3, 2, 3), t)
98
99
100func _run() -> void:
101 persist = map.get("_persist")
102 level = map.get("level")
103 persist.refold()
104 map._ensure_ops()
105 var op: Node = map.get_node_or_null("BoxCutter")
106 # ---- B0 ----
107 _ok("B0 the map has the BoxCutter operator", op != null and op.has_method("place"))
108 if op == null:
109 return
110
111 # ---- B1 / B2 ----
112 var t1 := _box(Vector3(0, 0, 0), Vector3(4, 2, 4))
113 var ns := _n("shape.create")
114 var no := _n("op.create")
115 _draw(op, t1)
116 var ok1: bool = op.confirm()
117 var sc := _last("shape.create")
118 var oc := _last("op.create")
119 _ok("B1 one commit writes a prism shape and a subtract op", ok1 and _n("shape.create") == ns + 1 and _n("op.create") == no + 1
120 and String(sc.get("type", "")) == "prism" and String(oc.get("op", "")) == "subtract", [sc.get("type"), oc.get("op")])
121 _ok("B1 ... the op cuts the target with the new cutter", String(oc.get("cutter", "")) == "%s:%d" % [sc["author"], int(sc["seq"])]
122 and Array(oc.get("targets", [])) == [t1], [oc.get("cutter"), oc.get("targets")])
123 _ok("B2 through: air at mid-height under the rect", _count(t1, Vector3(2, 1, 2)) == 0)
124 _ok("B2 ... and at the very bottom", _count(t1, Vector3(2, 0.05, 2)) == 0)
125 _ok("B2 ... solid beside it, once", _count(t1, Vector3(0.5, 1, 0.5)) == 1)
126
127 # ---- B6 (on the first commit) ----
128 persist.undo_last()
129 persist.refold()
130 _ok("B6 one Ctrl+Z removes the whole commit", _n("shape.create") == ns and _n("op.create") == no, [_n("shape.create") - ns, _n("op.create") - no])
131 _ok("B6 ... the target is whole again", _count(t1, Vector3(2, 1, 2)) == 1)
132
133 # ---- B3 ----
134 var t3 := _box(Vector3(20, 0, 0), Vector3(4, 2, 4))
135 op.place(Vector3(21, 2, 1), Vector3.UP, Vector3(21, 2, 1), Vector3(23, 2, 3), t3)
136 op.handle_key(_key(KEY_X))
137 _ok("B3 X once = slice", String(op.mode()) == "slice", op.mode())
138 op.confirm()
139 _ok("B3 ... the committed op is a slice", String(_last("op.create").get("op", "")) == "slice")
140
141 # ---- B4 ----
142 var t4 := _box(Vector3(40, 0, 0), Vector3(4, 2, 4))
143 op.place(Vector3(41, 2, 1), Vector3.UP, Vector3(41, 2, 1), Vector3(43, 2, 3), t4)
144 op.handle_key(_key(KEY_I))
145 op.confirm()
146 _ok("B4 I = inset: air in the top 0.25 under the rect", String(_last("op.create").get("op", "")) == "inset"
147 and _count(t4, Vector3(42, 1.9, 2)) == 0, [_last("op.create").get("op"), _count(t4, Vector3(42, 1.9, 2))])
148 _ok("B4 ... solid below the pocket", _count(t4, Vector3(42, 1.5, 2)) == 1)
149
150 # ---- B5 ----
151 var t5 := _box(Vector3(60, 0, 0), Vector3(4, 2, 4))
152 op.place(Vector3(61, 2, 1), Vector3.UP, Vector3(61, 2, 1), Vector3(63, 2, 3), t5)
153 op.handle_key(_key(KEY_X)) # the mode is sticky: inset (B4) -> outset
154 op.handle_key(_key(KEY_X)) # -> back round to subtract
155 op.set_tool_setting("depth", 2)
156 var g: float = op.grid_step()
157 op.confirm()
158 _ok("B5 depth 2 steps: air one and a half steps down", String(_last("op.create").get("op", "")) == "subtract"
159 and _count(t5, Vector3(62, 2 - 1.5 * g, 2)) == 0, [_last("op.create").get("op"), g])
160 _ok("B5 ... solid two and a half steps down", _count(t5, Vector3(62, 2 - 2.5 * g, 2)) == 1)
scenes/test/gate_box_cutter.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_box_cutter.gd" id="1"]
4
5[node name="BoxCutterGate" type="Node"]
6script = ExtResource("1")