OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / e3a087d

#120 L2 step 4: BoxCutter footprints and surfaces (§13.2)
Shapes: box (corner to corner), circle (24 segments) and ngon (sides setting) from a centre and radius, custom polygon (P adds points, Backspace removes; used as its convex hull by the prism). Surfaces: the aimed face, the view through the 3D cursor, the cursor's plane, the world grid at the cursor's height. D opens the tool's menu (BoxCutter's pie) for both; tool settings carry shape / sides / surface. gate_box_cutter B7–B11 (23 total). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
e3a087d80bac8c7cd4168c025ef98d2f0f4c87a3
by plankatron <fleeky@gmail.com> · 2026-10-19T00:56:09.000Z

Changed files (2) +193 -19

scenes/maps/operators/box_cutter_operator.gd +142 -18
……@@ -44,6 +44,94 @@ var _depth_steps := 0 # 0 = THROUGH
4444var _valid := false
4545var _frozen := false # placed by place() (gates, Adjust Last): the aim does not move it
4646
47# ---- step 4: footprints and surfaces (§13.2, BoxCutter's D pie) ----
48const SHAPES := ["box", "circle", "ngon", "custom"]
49const SURFACES := ["face", "view", "cursor", "grid"]
50var _shape := "box" # box: corner to corner · circle / ngon: centre, then radius
51var _sides := 6 # ngon
52var _segments := 24 # circle
53var _points: Array = [] # custom: the P points, on the plane
54var _surface := "face" # which plane the footprint is drawn on
55
56
57## Place a CUSTOM footprint directly (gates): the plane, its points, the target.
58func place_points(o: Vector3, n: Vector3, pts: Array, target: String) -> void:
59 place(o, n, o, o, target)
60 _points = pts.duplicate()
61
62
63## The plane the current SURFACE draws on, as [point, normal], from the aim `hit` ({} when
64## there is none). face = the aimed face · view = facing you through the 3D cursor ·
65## cursor = the cursor's own plane (its up) · grid = the world's level plane at the
66## cursor's height. A surface with no plane yet (face, no aim) returns [].
67func surface_plane(hit: Dictionary = {}) -> Array:
68 var cur: Node3D = map.get("_cursor3d") as Node3D
69 var cp: Vector3 = cur.global_position if cur != null else Vector3.ZERO
70 match _surface:
71 "view":
72 var ray: Dictionary = map._crosshair_ray()
73 var d: Vector3 = ray.get("dir", Vector3.FORWARD)
74 return [cp, -d.normalized()]
75 "cursor":
76 return [cp, (cur.global_basis.y if cur != null else Vector3.UP).normalized()]
77 "grid":
78 return [cp, Vector3.UP]
79 return [] if hit.is_empty() else [hit["position"], hit.get("normal", Vector3.UP)]
80
81
82## D: the tool's menu (BoxCutter's pie) — the shape and the surface.
83func _open_menu() -> void:
84 var q: Node = map.get("_q_menu")
85 if q == null:
86 return
87 var rows: Array = []
88 for s: String in SHAPES:
89 rows.append(["Shape: %s%s" % [s.capitalize(), " ✓" if s == _shape else ""],
90 set_tool_setting.bind("shape", s)])
91 for s: String in SURFACES:
92 rows.append(["Surface: %s%s" % [s.capitalize(), " ✓" if s == _surface else ""],
93 set_tool_setting.bind("surface", s)])
94 q.open_choices("BoxCutter", rows, map.get_viewport().get_mouse_position())
95
96
97## The footprint in the plane's (u, v): {lo: Vector3, w, l, poly: unit [[u, v], …]}, or {}
98## when it has no area yet.
99func _footprint() -> Dictionary:
100 match _shape:
101 "circle", "ngon":
102 var dv := _b - _a
103 var r := (dv - _n * dv.dot(_n)).length()
104 if r < 1.0e-3:
105 return {}
106 var n := _segments if _shape == "circle" else _sides
107 return {"lo": _a - (_u + _v) * r, "w": 2.0 * r, "l": 2.0 * r,
108 "poly": ParamShapes.get_type("prism").ngon_poly(n)}
109 "custom":
110 if _points.size() < 3:
111 return {}
112 var u0 := INF
113 var u1 := -INF
114 var v0 := INF
115 var v1 := -INF
116 for p: Vector3 in _points:
117 u0 = minf(u0, (p - _o).dot(_u))
118 u1 = maxf(u1, (p - _o).dot(_u))
119 v0 = minf(v0, (p - _o).dot(_v))
120 v1 = maxf(v1, (p - _o).dot(_v))
121 if u1 - u0 < 1.0e-3 or v1 - v0 < 1.0e-3:
122 return {}
123 var poly: Array = []
124 for p2: Vector3 in _points:
125 poly.append([((p2 - _o).dot(_u) - u0) / (u1 - u0), ((p2 - _o).dot(_v) - v0) / (v1 - v0)])
126 return {"lo": _o + _u * u0 + _v * v0, "w": u1 - u0, "l": v1 - v0, "poly": poly}
127 var r4 := _rect()
128 var lo: Vector3 = r4[0]
129 var w := (r4[1] as Vector3 - lo).length()
130 var l := (r4[3] as Vector3 - lo).length()
131 if w < 1.0e-3 or l < 1.0e-3:
132 return {}
133 return {"lo": lo, "w": w, "l": l, "poly": [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]}
134
47135
48136func op_name() -> String:
49137 return "boxcutter"
……@@ -61,6 +149,7 @@ func setup_modal(m: ModalControls) -> void:
61149 m.confirm_label = "cut"
62150 m.table([["P", "first corner", func() -> String: return "set — aim for the other" if _phase == Phase.DRAW else "aim, then P"],
63151 ["X / I", "mode", func() -> String: return mode()],
152 ["D", "shape / surface", func() -> String: return "%s on %s" % [_shape, _surface]],
64153 ["E / Space", "depth", func() -> String: return _depth_text()],
65154 ["", "aim", func() -> String: return "" if _valid else "aim at a surface"]])
66155 m.bind("depth", "depth", func() -> float: return float(_depth_steps), _set_depth,
……@@ -109,10 +198,23 @@ func handle_key(event: InputEvent) -> bool:
109198 match KeyRules.key_of(k):
110199 KEY_P:
111200 if _valid:
112 _a = _b if _phase == Phase.DRAW else _a
201 if _shape == "custom":
202 if _phase != Phase.DRAW:
203 _points.clear()
204 _points.append(_b if _phase == Phase.DRAW else _a)
205 else:
206 _a = _b if _phase == Phase.DRAW else _a
113207 _phase = Phase.DRAW
114208 update_preview()
115209 return true
210 KEY_BACKSPACE:
211 if _shape == "custom" and not _points.is_empty():
212 _points.pop_back()
213 update_preview()
214 return true
215 KEY_D:
216 _open_menu()
217 return true
116218 KEY_X:
117219 _mode = (_mode + 1) % MODES.size()
118220 return true
……@@ -201,8 +303,11 @@ func update_preview() -> void:
201303 _level_id = int(b.get("level_id", 0))
202304 var g := String(level.get_brush_group(brush))
203305 _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)
306 var pl := surface_plane(hit)
307 _set_plane(pl[0], pl[1])
308 # Off the aimed face, the first point is where the aim crosses the chosen plane.
309 var on: Variant = _aim_on_plane() if _surface != "face" else null
310 _a = _snap(on if on != null else _o)
206311 _b = _a + (_u + _v) * grid_step()
207312 _valid = true
208313 else:
……@@ -250,23 +355,29 @@ func _through() -> float:
250355## [frame, size] of the prism: x = u, y = INTO the surface, z = v (right-handed: u × −n = v);
251356## the bottom one grid step above the surface, the size reaching the depth below it.
252357func _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()
358 var fp := _footprint()
359 if fp.is_empty():
360 return []
257361 var m := grid_step()
258 var f := Transform3D(Basis(_u, -_n, _v), lo + _n * m)
259 return [f, Vector3(w, _depth() + m, l)]
362 var f := Transform3D(Basis(_u, -_n, _v), (fp["lo"] as Vector3) + _n * m)
363 return [f, Vector3(float(fp["w"]), _depth() + m, float(fp["l"])), fp["poly"]]
260364
261365
366## The footprint's outline on the surface and at the depth, joined: the cut you will get.
262367func _edges() -> PackedVector3Array:
263368 var out := PackedVector3Array()
264 var r := _rect()
369 var fp := _footprint()
370 if fp.is_empty():
371 return out
265372 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]))
373 var lo: Vector3 = fp["lo"]
374 var pts: Array = []
375 for q: Array in (fp["poly"] as Array):
376 pts.append(lo + _u * (float(q[0]) * float(fp["w"])) + _v * (float(q[1]) * float(fp["l"])))
377 for i in pts.size():
378 var p: Vector3 = pts[i]
379 var q2: Vector3 = pts[(i + 1) % pts.size()]
380 out.append_array(PackedVector3Array([p, q2, p + down, q2 + down, p, p + down]))
270381 return out
271382
272383
……@@ -276,6 +387,12 @@ func tool_settings() -> Array:
276387 "tip": "X cycles, I = inset (BoxCutter's keys)."},
277388 {"id": "depth", "label": "Depth", "type": "int", "value": _depth_steps, "min": 0, "step": 1,
278389 "unit": "grid", "tip": "Below the surface, in grid steps; 0 = through (Space)."},
390 {"id": "shape", "label": "Shape", "type": "enum", "value": SHAPES.find(_shape), "choices": SHAPES,
391 "tip": "The footprint (D menu): box, circle, ngon, custom polygon."},
392 {"id": "sides", "label": "Sides", "type": "int", "value": _sides, "min": 3, "max": 64, "step": 1,
393 "tip": "An ngon's sides."},
394 {"id": "surface", "label": "Surface", "type": "enum", "value": SURFACES.find(_surface), "choices": SURFACES,
395 "tip": "Where it is drawn (D menu): the aimed face, the view, the 3D cursor, the grid."},
279396 {"id": "cut", "label": "Cut", "type": "action", "tip": "Commit (LMB / Enter / Space)."},
280397 ]
281398
……@@ -286,6 +403,12 @@ func set_tool_setting(sid: String, value: Variant) -> bool:
286403 _mode = posmod(int(value), MODES.size())
287404 "depth":
288405 _depth_steps = maxi(0, int(value))
406 "shape":
407 _shape = String(value) if value is String else String(SHAPES[posmod(int(value), SHAPES.size())])
408 "sides":
409 _sides = clampi(int(value), 3, 64)
410 "surface":
411 _surface = String(value) if value is String else String(SURFACES[posmod(int(value), SURFACES.size())])
289412 "cut":
290413 confirm_via_stack()
291414 return true
……@@ -304,15 +427,15 @@ func confirm() -> bool:
304427 print("[boxcutter] draw a rect first: aim, P, aim")
305428 return false
306429 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")
430 if fs.is_empty():
431 print("[boxcutter] the footprint has no area yet: aim away from the first point (custom: 3 points)")
310432 return false
433 var size: Vector3 = fs[1]
311434 var prism: Object = ParamShapes.get_type("prism")
312435 map._ensure_persistence()
313436 map._persist.begin_gesture(gesture_label())
314437 var dot: String = map._persist.record("shape.create", {"type": "prism", "gv": prism.GV,
315 "params": prism.resolve({}), "frame": SceneObjects.xform_to(fs[0]),
438 "params": prism.resolve({"poly": fs[2]}), "frame": SceneObjects.xform_to(fs[0]),
316439 "size": [size.x, size.y, size.z], "material_id": _material, "level_id": _level_id})
317440 var ok := dot != ""
318441 if ok:
……@@ -329,4 +452,5 @@ func confirm() -> bool:
329452 ModalControls.summary("BoxCutter %s · %s" % [mode(), _depth_text()])
330453 _phase = Phase.AIM
331454 _frozen = false
455 _points.clear()
332456 return true
scenes/test/gate_box_cutter.gd +51 -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 < 12:
42 if checks < 21:
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])
……@@ -158,3 +158,53 @@ func _run() -> void:
158158 _ok("B5 depth 2 steps: air one and a half steps down", String(_last("op.create").get("op", "")) == "subtract"
159159 and _count(t5, Vector3(62, 2 - 1.5 * g, 2)) == 0, [_last("op.create").get("op"), g])
160160 _ok("B5 ... solid two and a half steps down", _count(t5, Vector3(62, 2 - 2.5 * g, 2)) == 1)
161
162 # ---- step 4: footprints and surfaces (§13.2) ----
163 # B7 circle: P sets the CENTRE, the aim the radius (1 here); a 24-segment footprint.
164 var t7 := _box(Vector3(80, 0, 0), Vector3(4, 2, 4))
165 op.set_tool_setting("depth", 0) # the depth is sticky (B5 set 2 steps): through again
166 op.set_tool_setting("shape", "circle")
167 op.place(Vector3(82, 2, 2), Vector3.UP, Vector3(82, 2, 2), Vector3(83, 2, 2), t7)
168 op.confirm()
169 var poly7: Array = (_last("shape.create").get("params", {}) as Dictionary).get("poly", [])
170 _ok("B7 circle: a 24-point footprint", poly7.size() == 24, poly7.size())
171 _ok("B7 ... the centre is cut through", _count(t7, Vector3(82, 1, 2)) == 0)
172 _ok("B7 ... the rect's corner (outside the circle) is not", _count(t7, Vector3(81.1, 1, 1.1)) == 1)
173 # B8 ngon: 6 sides.
174 var t8 := _box(Vector3(90, 0, 0), Vector3(4, 2, 4))
175 op.set_tool_setting("shape", "ngon")
176 op.set_tool_setting("sides", 6)
177 op.place(Vector3(92, 2, 2), Vector3.UP, Vector3(92, 2, 2), Vector3(93, 2, 2), t8)
178 op.confirm()
179 var poly8: Array = (_last("shape.create").get("params", {}) as Dictionary).get("poly", [])
180 _ok("B8 ngon 6: a 6-point footprint, cut at its centre", poly8.size() == 6 and _count(t8, Vector3(92, 1, 2)) == 0, poly8.size())
181 # B9 custom polygon: a triangle from three P points.
182 var t9 := _box(Vector3(100, 0, 0), Vector3(4, 2, 4))
183 op.set_tool_setting("shape", "custom")
184 op.place_points(Vector3(101, 2, 1), Vector3.UP, [Vector3(101, 2, 1), Vector3(103, 2, 1), Vector3(101, 2, 3)], t9)
185 op.confirm()
186 _ok("B9 custom triangle: inside it is cut", _count(t9, Vector3(101.4, 1, 1.4)) == 0)
187 _ok("B9 ... past its long side is not", _count(t9, Vector3(102.6, 1, 2.6)) == 1)
188 # B10 surfaces: the plane each one draws on.
189 var cur: Node3D = map.get("_cursor3d")
190 cur.global_position = Vector3(120, 3, 0)
191 op.set_tool_setting("surface", "grid")
192 var pg: Array = op.surface_plane()
193 _ok("B10 grid: the world's level plane at the cursor's height", (pg[1] as Vector3).is_equal_approx(Vector3.UP)
194 and is_equal_approx((pg[0] as Vector3).y, 3.0), pg)
195 op.set_tool_setting("surface", "cursor")
196 var pc: Array = op.surface_plane()
197 _ok("B10 cursor: the cursor's own plane (its up)", (pc[1] as Vector3).is_equal_approx(cur.global_basis.y.normalized())
198 and (pc[0] as Vector3).is_equal_approx(cur.global_position), pc)
199 op.set_tool_setting("surface", "face")
200 op.set_tool_setting("shape", "box")
201 # B11 D opens the tool's menu: the four shapes and the four surfaces.
202 var q: Node = map.get("_q_menu")
203 op.handle_key(_key(KEY_D))
204 var rows: Array = []
205 var menu: PopupMenu = q.get("_menu")
206 for i in menu.item_count:
207 rows.append(menu.get_item_text(i))
208 _ok("B11 D opens the menu with the shapes and surfaces", q.is_open() and str(rows).contains("Circle")
209 and str(rows).contains("Custom") and str(rows).contains("Grid") and str(rows).contains("View"), rows)
210 menu.hide()