OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 67327c9

#120 L2 step 5: BoxCutter modifiers while drawing (§13.4)
B chamfer, V array / V V radial, 1/2/3 mirror on the world's X/Y/Z across the target's centre (mapped to the parallel cutter-frame axis), Shift+G grid: a queue written as mod.create on the cutter at commit (the same key again takes it off). L writes the cut OFF. Taper / wedge in the D menu and tool settings, stored as the prism's params. gate_box_cutter M1–M4 (31 total). Doc: Shift+N mirror flip unbound (no bisect); Y extract moved to step 7. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
67327c968df692ca9e8e31b896fc402181f2b82a
by plankatron <fleeky@gmail.com> · 2026-10-19T01:11:35.000Z

Changed files (3) +199 -6

docs/node-tree-and-stacks.md +7 -3
……@@ -805,7 +805,10 @@ collisions.
805805- **Modifier keys while drawing** (BoxCutter's) queue `mod.create` records on the cutter:
806806 - **B** chamfer: the wheel or [ ] steps segments;
807807 - **V** array, **V V** radial about the cursor;
808 - **1 / 2 / 3** mirror X / Y / Z; **Shift+1/2/3** flips that mirror's side;
808 - **1 / 2 / 3** mirror across the TARGET's centre on the world's X / Y / Z (each goes to
809 the cutter-frame axis most nearly parallel to it). BoxCutter's Shift+1/2/3 "flip" only
810 means something with a bisect, which a reflected cutter does not have, so it is not
811 bound;
809812 - **Shift+G** grid. BoxCutter binds no grid key, and G is grab; #120 lists Shift+G.
810813
811814 The queue shows on the status line; pressing the same key again removes it. ModFold
……@@ -844,8 +847,9 @@ collisions.
8448472. Through-depth function: `gate_cut_through` (slab, stepped target, two targets).
8458483. The tool, rect on a face, mode X, commit as one gesture: `gate_box_cutter`.
8468494. Footprints circle / ngon / polygon and surfaces view / cursor / grid: `gate_box_cutter` B-checks.
8475. Modifier keys while drawing (B, V / V V, 1/2/3 + Shift, Shift+G), Y extract, L, and the D
848 menu's taper / wedge: `gate_box_cutter` M-checks.
8505. Modifier keys while drawing (B, V / V V, 1/2/3, Shift+G), L, and the D menu's taper /
851 wedge: `gate_box_cutter` M-checks. (Y extract moved to step 7: it shares the "cut result
852 → new shapes" code with the Q menu's Extract.)
8498536. Whole-selection targets, the Cutters collection, repeat: `gate_box_cutter` H-checks.
8508547. Q menu Change cut, Extract, Apply up to here: `gate_q_stack`.
8518558. Stack scroll preview: `gate_q_stack` S-checks.
scenes/maps/operators/box_cutter_operator.gd +136 -2
……@@ -53,6 +53,84 @@ var _segments := 24 # circle
5353var _points: Array = [] # custom: the P points, on the plane
5454var _surface := "face" # which plane the footprint is drawn on
5555
56# ---- step 5: modifiers while drawing (§13.4, BoxCutter's keys) ----
57## The queue, written as mod.create records on the cutter at commit and then emptied:
58## B chamfer · V array (V V radial) · 1 / 2 / 3 mirror x / y / z · Shift+G grid. The same
59## key again takes it off. Mirror and radial work about the TARGET's centre, as
60## BoxCutter's do about the object.
61var _q_chamfer := false
62var _q_array := "" # "" / "array" / "radial"
63var _q_mirror := [false, false, false]
64var _q_grid := false
65var _live := true # L: false = the cut is written OFF
66var _taper := 1.0 # the prism's (D menu)
67var _shear_x := 0.0
68var _shear_z := 0.0
69
70
71func _queue_text() -> String:
72 var q: Array = []
73 for i in 3:
74 if _q_mirror[i]:
75 q.append("mirror " + "xyz"[i])
76 if _q_array != "":
77 q.append(_q_array)
78 if _q_grid:
79 q.append("grid")
80 if _q_chamfer:
81 q.append("chamfer")
82 return ", ".join(q) if not q.is_empty() else "none"
83
84
85func _clear_queue() -> void:
86 _q_chamfer = false
87 _q_array = ""
88 _q_mirror = [false, false, false]
89 _q_grid = false
90 _live = true
91
92
93## The target's centre: the middle of its solid pieces' corners.
94func _target_centre() -> Vector3:
95 var lo := Vector3(INF, INF, INF)
96 var hi := -lo
97 for b: int in (level.get_group_brush_ids(_target) as PackedInt32Array):
98 var bd: Dictionary = level.get_brush(b)
99 if bool(bd.get("clearance", false)) or bool(bd.get("cutter", false)):
100 continue
101 for c: Vector3 in FaceKit.corners(bd["planes"]):
102 lo = lo.min(c)
103 hi = hi.max(c)
104 return _o if lo.x > hi.x else (lo + hi) * 0.5
105
106
107## The queued modifiers as [kind, params] for the cutter framed `f` (the prism's frame):
108## mirror's offset puts its plane through the target's centre along that frame axis;
109## radial pivots there. The rest take the map's defaults.
110func _mod_params(f: Transform3D) -> Array:
111 var out: Array = []
112 var c := _target_centre()
113 var b := f.basis.orthonormalized()
114 # 1 / 2 / 3 are the WORLD's X / Y / Z (BoxCutter's are the object's); ModFold mirrors along
115 # a FRAME axis, so each world axis goes to the frame axis most nearly parallel to it.
116 for k in 3:
117 if not _q_mirror[k]:
118 continue
119 var best := 0
120 for i in 3:
121 if absf(b[i][k]) > absf(b[best][k]):
122 best = i
123 out.append(["mirror", {"axis": "xyz"[best], "offset": b[best].dot(c - f.origin)}])
124 if _q_array == "array":
125 out.append(["array", {}])
126 elif _q_array == "radial":
127 out.append(["radial", {"pivot": [c.x, c.y, c.z]}])
128 if _q_grid:
129 out.append(["grid", {}])
130 if _q_chamfer:
131 out.append(["chamfer", {}])
132 return out
133
56134
57135## Place a CUSTOM footprint directly (gates): the plane, its points, the target.
58136func place_points(o: Vector3, n: Vector3, pts: Array, target: String) -> void:
……@@ -91,9 +169,21 @@ func _open_menu() -> void:
91169 for s: String in SURFACES:
92170 rows.append(["Surface: %s%s" % [s.capitalize(), " ✓" if s == _surface else ""],
93171 set_tool_setting.bind("surface", s)])
172 # Taper and wedge: the prism's values (BoxCutter binds no key; its T is solidify).
173 for t: float in [1.0, 0.75, 0.5]:
174 rows.append(["Taper: %s%s" % [str(t), " ✓" if is_equal_approx(t, _taper) else ""],
175 set_tool_setting.bind("taper", t)])
176 for w: Array in [["none", 0.0, 0.0], ["X", 0.5, 0.0], ["Z", 0.0, 0.5]]:
177 rows.append(["Wedge: %s" % w[0], _set_wedge.bind(float(w[1]), float(w[2]))])
94178 q.open_choices("BoxCutter", rows, map.get_viewport().get_mouse_position())
95179
96180
181func _set_wedge(x: float, z: float) -> void:
182 _shear_x = x
183 _shear_z = z
184 update_preview()
185
186
97187## The footprint in the plane's (u, v): {lo: Vector3, w, l, poly: unit [[u, v], …]}, or {}
98188## when it has no area yet.
99189func _footprint() -> Dictionary:
……@@ -150,6 +240,8 @@ func setup_modal(m: ModalControls) -> void:
150240 m.table([["P", "first corner", func() -> String: return "set — aim for the other" if _phase == Phase.DRAW else "aim, then P"],
151241 ["X / I", "mode", func() -> String: return mode()],
152242 ["D", "shape / surface", func() -> String: return "%s on %s" % [_shape, _surface]],
243 ["B V 1-3 ⇧G", "modifiers", func() -> String: return _queue_text()],
244 ["L", "live", func() -> String: return "on" if _live else "OFF (written off)"],
153245 ["E / Space", "depth", func() -> String: return _depth_text()],
154246 ["", "aim", func() -> String: return "" if _valid else "aim at a surface"]])
155247 m.bind("depth", "depth", func() -> float: return float(_depth_steps), _set_depth,
……@@ -215,6 +307,25 @@ func handle_key(event: InputEvent) -> bool:
215307 KEY_D:
216308 _open_menu()
217309 return true
310 # Modifiers while drawing (BoxCutter's): the same key again takes it off.
311 KEY_B:
312 _q_chamfer = not _q_chamfer
313 return true
314 KEY_V:
315 _q_array = {"": "array", "array": "radial", "radial": ""}[_q_array]
316 return true
317 KEY_1, KEY_2, KEY_3:
318 var i := int(KeyRules.key_of(k) - KEY_1)
319 _q_mirror[i] = not bool(_q_mirror[i])
320 return true
321 KEY_G:
322 if not k.shift_pressed:
323 return false # bare G is grab, not ours
324 _q_grid = not _q_grid
325 return true
326 KEY_L:
327 _live = not _live
328 return true
218329 KEY_X:
219330 _mode = (_mode + 1) % MODES.size()
220331 return true
……@@ -391,6 +502,12 @@ func tool_settings() -> Array:
391502 "tip": "The footprint (D menu): box, circle, ngon, custom polygon."},
392503 {"id": "sides", "label": "Sides", "type": "int", "value": _sides, "min": 3, "max": 64, "step": 1,
393504 "tip": "An ngon's sides."},
505 {"id": "taper", "label": "Taper", "type": "float", "value": _taper, "min": 0.05, "max": 4.0, "step": 0.05,
506 "tip": "The far end's size against the footprint (1 = straight)."},
507 {"id": "shear_x", "label": "Wedge X", "type": "float", "value": _shear_x, "min": -4.0, "max": 4.0, "step": 0.05,
508 "tip": "The far end's offset along the footprint's x, a fraction of its width."},
509 {"id": "shear_z", "label": "Wedge Z", "type": "float", "value": _shear_z, "min": -4.0, "max": 4.0, "step": 0.05,
510 "tip": "The far end's offset along the footprint's z, a fraction of its length."},
394511 {"id": "surface", "label": "Surface", "type": "enum", "value": SURFACES.find(_surface), "choices": SURFACES,
395512 "tip": "Where it is drawn (D menu): the aimed face, the view, the 3D cursor, the grid."},
396513 {"id": "cut", "label": "Cut", "type": "action", "tip": "Commit (LMB / Enter / Space)."},
……@@ -407,6 +524,12 @@ func set_tool_setting(sid: String, value: Variant) -> bool:
407524 _shape = String(value) if value is String else String(SHAPES[posmod(int(value), SHAPES.size())])
408525 "sides":
409526 _sides = clampi(int(value), 3, 64)
527 "taper":
528 _taper = clampf(float(value), 0.05, 4.0)
529 "shear_x":
530 _shear_x = clampf(float(value), -4.0, 4.0)
531 "shear_z":
532 _shear_z = clampf(float(value), -4.0, 4.0)
410533 "surface":
411534 _surface = String(value) if value is String else String(SURFACES[posmod(int(value), SURFACES.size())])
412535 "cut":
……@@ -435,12 +558,22 @@ func confirm() -> bool:
435558 map._ensure_persistence()
436559 map._persist.begin_gesture(gesture_label())
437560 var dot: String = map._persist.record("shape.create", {"type": "prism", "gv": prism.GV,
438 "params": prism.resolve({"poly": fs[2]}), "frame": SceneObjects.xform_to(fs[0]),
561 "params": prism.resolve({"poly": fs[2], "taper": _taper, "shear_x": _shear_x, "shear_z": _shear_z}),
562 "frame": SceneObjects.xform_to(fs[0]),
439563 "size": [size.x, size.y, size.z], "material_id": _material, "level_id": _level_id})
440564 var ok := dot != ""
441565 if ok:
442566 var rec := {"op": mode(), "cutter": dot, "targets": [_target]}
443 ok = map._persist.record("op.create", rec) != ""
567 var odot: String = map._persist.record("op.create", rec)
568 ok = odot != ""
569 if ok and not _live:
570 map._persist.record("op.set", {"target": odot, "on": false}) # L: written off
571 # The queued modifiers, on the CUTTER (ModFold copies its pieces as cutters).
572 var defaults: Dictionary = map.get("MOD_DEFAULTS") if map.get("MOD_DEFAULTS") is Dictionary else {}
573 for kp: Array in _mod_params(fs[0]):
574 var p: Dictionary = (defaults.get(kp[0], {}) as Dictionary).duplicate(true)
575 p.merge(kp[1] as Dictionary, true)
576 map._persist.record("mod.create", {"target": dot, "mod": kp[0], "params": p})
444577 map._persist.end_gesture()
445578 if not ok:
446579 print("[boxcutter] the journal refused the cut")
……@@ -453,4 +586,5 @@ func confirm() -> bool:
453586 _phase = Phase.AIM
454587 _frozen = false
455588 _points.clear()
589 _clear_queue()
456590 return true
scenes/test/gate_box_cutter.gd +56 -1
……@@ -39,7 +39,7 @@ func _ready() -> void:
3939 await get_tree().process_frame
4040 await _run()
4141 get_tree().root.get_node("/root/GameMode").set_mode("play")
42 if checks < 21:
42 if checks < 29:
4343 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
4444 fails += 1
4545 print("\nGATE box_cutter: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
……@@ -208,3 +208,58 @@ func _run() -> void:
208208 _ok("B11 D opens the menu with the shapes and surfaces", q.is_open() and str(rows).contains("Circle")
209209 and str(rows).contains("Custom") and str(rows).contains("Grid") and str(rows).contains("View"), rows)
210210 menu.hide()
211
212 # ---- step 5: modifiers while drawing (§13.4, BoxCutter's keys) ----
213 # M1 1 = mirror X across the TARGET's centre: a hole at x 140.5..141.5 gets its twin at
214 # 142.5..143.5 (target x 140..144, centre 142); between them stays solid.
215 var tm := _box(Vector3(140, 0, 0), Vector3(4, 2, 4))
216 op.place(Vector3(140.5, 2, 1), Vector3.UP, Vector3(140.5, 2, 1), Vector3(141.5, 2, 3), tm)
217 op.handle_key(_key(KEY_1))
218 op.confirm()
219 var cm := _last("shape.create")
220 var cdot := "%s:%d" % [cm["author"], int(cm["seq"])]
221 _ok("M1 1 queues a mirror on the cutter", String(_last("mod.create").get("target", "")) == cdot
222 and String(_last("mod.create").get("mod", "")) == "mirror", [_last("mod.create").get("target"), _last("mod.create").get("mod")])
223 _ok("M1 ... the original hole", _count(tm, Vector3(141, 1, 2)) == 0)
224 _ok("M1 ... its mirror twin across the target's centre", _count(tm, Vector3(143, 1, 2)) == 0)
225 _ok("M1 ... and solid between them", _count(tm, Vector3(142, 1, 2)) == 1)
226 # M2 the queue empties after a commit; B and V V queue chamfer and radial; the same key
227 # again (B) takes it off.
228 var tq := _box(Vector3(160, 0, 0), Vector3(4, 2, 4))
229 op.place(Vector3(161, 2, 1), Vector3.UP, Vector3(161, 2, 1), Vector3(163, 2, 3), tq)
230 var nm := _n("mod.create")
231 op.handle_key(_key(KEY_B))
232 op.handle_key(_key(KEY_B)) # off again
233 op.handle_key(_key(KEY_V))
234 op.handle_key(_key(KEY_V)) # V V = radial
235 op.confirm()
236 # Only THIS cutter's modifiers (the window must not reach M1's mirror).
237 var cq := _last("shape.create")
238 var cqd := "%s:%d" % [cq["author"], int(cq["seq"])]
239 var kinds: Array = []
240 for r: Dictionary in (persist.records(false) as Array):
241 if String(r.get("kind", "")) == "mod.create" and String(r.get("target", "")) == cqd:
242 kinds.append(String(r.get("mod", "")))
243 _ok("M2 B B (off again) + V V: exactly one radial, no chamfer, no mirror carried over", _n("mod.create") == nm + 1 and kinds == ["radial"], kinds)
244 # M3 Shift+G = grid; L = the cut starts OFF (the target uncut, the op there).
245 var tl := _box(Vector3(180, 0, 0), Vector3(4, 2, 4))
246 op.place(Vector3(181, 2, 1), Vector3.UP, Vector3(181, 2, 1), Vector3(183, 2, 3), tl)
247 var sg := _key(KEY_G)
248 sg.shift_pressed = true
249 op.handle_key(sg)
250 op.handle_key(_key(KEY_L))
251 op.confirm()
252 _ok("M3 Shift+G queues a grid", String(_last("mod.create").get("mod", "")) == "grid")
253 _ok("M3 L: the cut is written OFF — the target is uncut", _count(tl, Vector3(182, 1, 2)) == 1
254 and String(_last("op.create").get("op", "")) == "subtract")
255 # M4 taper and wedge (the D menu's values) ride in the prism's params.
256 var tt := _box(Vector3(200, 0, 0), Vector3(4, 2, 4))
257 op.set_tool_setting("taper", 0.5)
258 op.set_tool_setting("shear_x", 0.25)
259 op.place(Vector3(201, 2, 1), Vector3.UP, Vector3(201, 2, 1), Vector3(203, 2, 3), tt)
260 op.confirm()
261 var pp: Dictionary = _last("shape.create").get("params", {})
262 _ok("M4 taper 0.5 and shear 0.25 are the prism's params", is_equal_approx(float(pp.get("taper", 0)), 0.5)
263 and is_equal_approx(float(pp.get("shear_x", 0)), 0.25), pp)
264 op.set_tool_setting("taper", 1.0)
265 op.set_tool_setting("shear_x", 0.0)