| … | … | @@ -44,6 +44,94 @@ var _depth_steps := 0 # 0 = THROUGH |
| 44 | 44 | var _valid := false |
| 45 | 45 | var _frozen := false # placed by place() (gates, Adjust Last): the aim does not move it |
| 46 | 46 | |
| 47 | # ---- step 4: footprints and surfaces (§13.2, BoxCutter's D pie) ---- |
| 48 | const SHAPES := ["box", "circle", "ngon", "custom"] |
| 49 | const SURFACES := ["face", "view", "cursor", "grid"] |
| 50 | var _shape := "box" # box: corner to corner · circle / ngon: centre, then radius |
| 51 | var _sides := 6 # ngon |
| 52 | var _segments := 24 # circle |
| 53 | var _points: Array = [] # custom: the P points, on the plane |
| 54 | var _surface := "face" # which plane the footprint is drawn on |
| 55 | |
| 56 | |
| 57 | ## Place a CUSTOM footprint directly (gates): the plane, its points, the target. |
| 58 | func 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 []. |
| 67 | func 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. |
| 83 | func _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. |
| 99 | func _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 | |
| 47 | 135 | |
| 48 | 136 | func op_name() -> String: |
| 49 | 137 | return "boxcutter" |
| … | … | @@ -61,6 +149,7 @@ func setup_modal(m: ModalControls) -> void: |
| 61 | 149 | m.confirm_label = "cut" |
| 62 | 150 | m.table([["P", "first corner", func() -> String: return "set — aim for the other" if _phase == Phase.DRAW else "aim, then P"], |
| 63 | 151 | ["X / I", "mode", func() -> String: return mode()], |
| 152 | ["D", "shape / surface", func() -> String: return "%s on %s" % [_shape, _surface]], |
| 64 | 153 | ["E / Space", "depth", func() -> String: return _depth_text()], |
| 65 | 154 | ["", "aim", func() -> String: return "" if _valid else "aim at a surface"]]) |
| 66 | 155 | m.bind("depth", "depth", func() -> float: return float(_depth_steps), _set_depth, |
| … | … | @@ -109,10 +198,23 @@ func handle_key(event: InputEvent) -> bool: |
| 109 | 198 | match KeyRules.key_of(k): |
| 110 | 199 | KEY_P: |
| 111 | 200 | 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 |
| 113 | 207 | _phase = Phase.DRAW |
| 114 | 208 | update_preview() |
| 115 | 209 | 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 |
| 116 | 218 | KEY_X: |
| 117 | 219 | _mode = (_mode + 1) % MODES.size() |
| 118 | 220 | return true |
| … | … | @@ -201,8 +303,11 @@ func update_preview() -> void: |
| 201 | 303 | _level_id = int(b.get("level_id", 0)) |
| 202 | 304 | var g := String(level.get_brush_group(brush)) |
| 203 | 305 | _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) |
| 206 | 311 | _b = _a + (_u + _v) * grid_step() |
| 207 | 312 | _valid = true |
| 208 | 313 | else: |
| … | … | @@ -250,23 +355,29 @@ func _through() -> float: |
| 250 | 355 | ## [frame, size] of the prism: x = u, y = INTO the surface, z = v (right-handed: u × −n = v); |
| 251 | 356 | ## the bottom one grid step above the surface, the size reaching the depth below it. |
| 252 | 357 | 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() |
| 358 | var fp := _footprint() |
| 359 | if fp.is_empty(): |
| 360 | return [] |
| 257 | 361 | 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"]] |
| 260 | 364 | |
| 261 | 365 | |
| 366 | ## The footprint's outline on the surface and at the depth, joined: the cut you will get. |
| 262 | 367 | func _edges() -> PackedVector3Array: |
| 263 | 368 | var out := PackedVector3Array() |
| 264 | | var r := _rect() |
| 369 | var fp := _footprint() |
| 370 | if fp.is_empty(): |
| 371 | return out |
| 265 | 372 | 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])) |
| 270 | 381 | return out |
| 271 | 382 | |
| 272 | 383 | |
| … | … | @@ -276,6 +387,12 @@ func tool_settings() -> Array: |
| 276 | 387 | "tip": "X cycles, I = inset (BoxCutter's keys)."}, |
| 277 | 388 | {"id": "depth", "label": "Depth", "type": "int", "value": _depth_steps, "min": 0, "step": 1, |
| 278 | 389 | "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."}, |
| 279 | 396 | {"id": "cut", "label": "Cut", "type": "action", "tip": "Commit (LMB / Enter / Space)."}, |
| 280 | 397 | ] |
| 281 | 398 | |
| … | … | @@ -286,6 +403,12 @@ func set_tool_setting(sid: String, value: Variant) -> bool: |
| 286 | 403 | _mode = posmod(int(value), MODES.size()) |
| 287 | 404 | "depth": |
| 288 | 405 | _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())]) |
| 289 | 412 | "cut": |
| 290 | 413 | confirm_via_stack() |
| 291 | 414 | return true |
| … | … | @@ -304,15 +427,15 @@ func confirm() -> bool: |
| 304 | 427 | print("[boxcutter] draw a rect first: aim, P, aim") |
| 305 | 428 | return false |
| 306 | 429 | 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)") |
| 310 | 432 | return false |
| 433 | var size: Vector3 = fs[1] |
| 311 | 434 | var prism: Object = ParamShapes.get_type("prism") |
| 312 | 435 | map._ensure_persistence() |
| 313 | 436 | map._persist.begin_gesture(gesture_label()) |
| 314 | 437 | 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]), |
| 316 | 439 | "size": [size.x, size.y, size.z], "material_id": _material, "level_id": _level_id}) |
| 317 | 440 | var ok := dot != "" |
| 318 | 441 | if ok: |
| … | … | @@ -329,4 +452,5 @@ func confirm() -> bool: |
| 329 | 452 | ModalControls.summary("BoxCutter %s · %s" % [mode(), _depth_text()]) |
| 330 | 453 | _phase = Phase.AIM |
| 331 | 454 | _frozen = false |
| 455 | _points.clear() |
| 332 | 456 | return true |