| … | … | @@ -0,0 +1,332 @@ |
| 1 | extends LevelOperator |
| 2 | class_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 | |
| 22 | const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd") |
| 23 | const 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. |
| 27 | const MODES := ["subtract", "slice", "intersect", "inset", "outset"] |
| 28 | const _UNIT := [Vector3.RIGHT, Vector3.UP, Vector3.BACK] |
| 29 | |
| 30 | enum Phase { AIM, DRAW } |
| 31 | |
| 32 | var _phase := Phase.AIM |
| 33 | var _mode := 0 |
| 34 | var _o := Vector3.ZERO # the drawn plane: a point on it, its normal, two axes in it |
| 35 | var _n := Vector3.UP |
| 36 | var _u := Vector3.BACK |
| 37 | var _v := Vector3.RIGHT |
| 38 | var _a := Vector3.ZERO # the first corner, on the plane |
| 39 | var _b := Vector3.ZERO # the other corner |
| 40 | var _target := "" # the group (or brush key) the cut goes into |
| 41 | var _material := 0 |
| 42 | var _level_id := 0 |
| 43 | var _depth_steps := 0 # 0 = THROUGH |
| 44 | var _valid := false |
| 45 | var _frozen := false # placed by place() (gates, Adjust Last): the aim does not move it |
| 46 | |
| 47 | |
| 48 | func op_name() -> String: |
| 49 | return "boxcutter" |
| 50 | |
| 51 | |
| 52 | func preview_color() -> Color: |
| 53 | return Color(1.0, 0.25, 0.2, 0.45) # red: about to cut |
| 54 | |
| 55 | |
| 56 | func mode() -> String: |
| 57 | return String(MODES[_mode]) |
| 58 | |
| 59 | |
| 60 | func 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 | |
| 70 | func _set_depth(v: float) -> void: |
| 71 | _depth_steps = maxi(0, int(roundf(v))) |
| 72 | update_preview() |
| 73 | |
| 74 | |
| 75 | func _depth_text() -> String: |
| 76 | return "through" if _depth_steps == 0 else fmt_units(_depth_steps * grid_units()) |
| 77 | |
| 78 | |
| 79 | func status() -> String: |
| 80 | return modal().status_text() |
| 81 | |
| 82 | |
| 83 | func readout() -> String: |
| 84 | return "%s · %s" % [mode(), _depth_text()] if _valid else "" |
| 85 | |
| 86 | |
| 87 | func 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 | |
| 97 | func on_deactivate() -> void: |
| 98 | _phase = Phase.AIM |
| 99 | _frozen = false |
| 100 | clear_preview() |
| 101 | |
| 102 | |
| 103 | func 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. |
| 137 | func 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 | |
| 147 | func _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). |
| 165 | func _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 | |
| 176 | func _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 | |
| 188 | func 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). |
| 217 | func _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. |
| 230 | func _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 | |
| 237 | func _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. |
| 252 | func _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 | |
| 262 | func _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 | |
| 273 | func 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 | |
| 283 | func 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 | |
| 298 | func gesture_label() -> String: |
| 299 | return "BoxCutter %s" % mode() |
| 300 | |
| 301 | |
| 302 | func 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 |