| … | … | @@ -821,13 +821,15 @@ collisions. |
| 821 | 821 | |
| 822 | 822 | ### 13.5 Housekeeping |
| 823 | 823 | |
| 824 | - **Targets = the whole selection.** `_live_cut` takes every selected object's group (not | |
| 825 | only the active one), excluding cutters. `op.create.targets` already takes many. | |
| 824 | - **Targets = the whole selection, in BoxCutter.** Its cut goes into the aimed object and | |
| 825 | every selected object's group, excluding cutters (`op.create.targets` takes many), and | |
| 826 | through-depth reaches through all of them. `_live_cut` (the Ctrl+numpad CSG commands) | |
| 827 | is UNCHANGED: there the other selected objects ARE the cutters. | |
| 826 | 828 | - **Cutters collection.** |
| 827 | 829 | - The first commit creates a room collection named "Cutters" (`col.create`, replicated |
| 828 | 830 | like any collection) and links every new cutter into it. |
| 829 | - Its outliner rows start hidden. That is view state, kept per player. | |
| 830 | - The Shift+Alt+C overlay still shows the wires. | |
| 831 | - It is NOT hidden: hiding its rows would hide the cutters' wires, which belong to the | |
| 832 | Shift+Alt+C overlay. It is organisation. | |
| 831 | 833 | - **Q menu on a shape with cuts:** |
| 832 | 834 | - **Change cut ▸** subtract / slice / intersect / inset / outset (`set_op`); |
| 833 | 835 | - **Apply up to here ▸** <op>: `make_real` takes an op: it bakes the target as folded |
| … | … | @@ -621,7 +621,7 @@ const ADD_SHAPES := ["box", "stairs", "spiral_stairs", "tunnel", "cylinder", "co |
| 621 | 621 | ## What Shift+R repeats: the commands that start a tool or apply an edit (not views, not |
| 622 | 622 | ## selection, not menus). Recorded as they fire (_note_repeatable). |
| 623 | 623 | const REPEATABLE := ["builder.move", "builder.rotate", "builder.scale", "builder.extrude", |
| 624 | "builder.duplicate", "builder.duplicate_linked", "builder.knife", "builder.create_box", "builder.texture_apply", | |
| 624 | "builder.duplicate", "builder.duplicate_linked", "builder.knife", "builder.create_box", "builder.box_cutter", "builder.texture_apply", | |
| 625 | 625 | "build_mode.csg_subtract", "build_mode.csg_intersect", "build_mode.csg_slice", |
| 626 | 626 | "build_mode.csg_merge", "builder.uv_hotspot", "builder.uv_fit", "builder.uv_align"] |
| 627 | 627 | var _last_repeatable := "" |
| … | … | @@ -4166,6 +4166,10 @@ func _builder_key(event: InputEvent) -> void: |
| 4166 | 4166 | if _last_repeatable == "": |
| 4167 | 4167 | print("[MapDCLevel] repeat last: nothing to repeat yet") |
| 4168 | 4168 | else: |
| 4169 | # BoxCutter repeats with the last cut's modifiers (#120 L2 §13.5). | |
| 4170 | var bcut := get_node_or_null("BoxCutter") | |
| 4171 | if _last_repeatable == "builder.box_cutter" and bcut != null: | |
| 4172 | bcut.restore_last_queue() | |
| 4169 | 4173 | Keymap.run(_last_repeatable) |
| 4170 | 4174 | return |
| 4171 | 4175 | if Keymap.tapped(event, "builder.element_toggle"): |
| … | … | @@ -90,6 +90,56 @@ func _clear_queue() -> void: |
| 90 | 90 | _live = true |
| 91 | 91 | |
| 92 | 92 | |
| 93 | # ---- step 6: housekeeping (§13.5) ---- | |
| 94 | ||
| 95 | ## The last commit's queue, for Shift+R (restore_last_queue). | |
| 96 | var _last_queue: Dictionary = {} | |
| 97 | ||
| 98 | ||
| 99 | ## Shift+R: the last cut's modifiers come back (the rest — mode, shape, depth — is sticky). | |
| 100 | func restore_last_queue() -> void: | |
| 101 | if _last_queue.is_empty(): | |
| 102 | return | |
| 103 | _q_chamfer = bool(_last_queue["chamfer"]) | |
| 104 | _q_array = String(_last_queue["array"]) | |
| 105 | _q_mirror = (_last_queue["mirror"] as Array).duplicate() | |
| 106 | _q_grid = bool(_last_queue["grid"]) | |
| 107 | ||
| 108 | ||
| 109 | func _remember_queue() -> void: | |
| 110 | _last_queue = {"chamfer": _q_chamfer, "array": _q_array, "mirror": _q_mirror.duplicate(), "grid": _q_grid} | |
| 111 | ||
| 112 | ||
| 113 | ## What the cut goes into: the aimed object, and every SELECTED object with it (each | |
| 114 | ## brush's group, or its key when it has none), never a cutter. | |
| 115 | func _targets() -> Array: | |
| 116 | var out: Array = [] | |
| 117 | if _target != "": | |
| 118 | out.append(_target) | |
| 119 | var sel: Variant = map.get("_sel_brushes") | |
| 120 | for b: Variant in (sel as Array if sel is Array else []): | |
| 121 | var bd: Dictionary = level.get_brush(int(b)) | |
| 122 | if bd.is_empty() or bool(bd.get("cutter", false)) or bool(bd.get("clearance", false)): | |
| 123 | continue | |
| 124 | var g := String(level.get_brush_group(int(b))) | |
| 125 | var t := g if g != "" else String(level.get_brush_key(int(b))) | |
| 126 | if t != "" and not out.has(t): | |
| 127 | out.append(t) | |
| 128 | return out | |
| 129 | ||
| 130 | ||
| 131 | ## The room's "Cutters" collection, created (inside the caller's gesture) the first time. | |
| 132 | func _cutters_collection() -> String: | |
| 133 | var t: Dictionary = map._persist.collection_tree() | |
| 134 | var cols: Dictionary = t.get("cols", {}) | |
| 135 | var keys: Array = cols.keys() | |
| 136 | keys.sort() | |
| 137 | for cd: String in keys: | |
| 138 | if String(cols[cd].get("name", "")) == "Cutters": | |
| 139 | return cd | |
| 140 | return String(map._persist.record("col.create", {"name": "Cutters", "parent": ""})) | |
| 141 | ||
| 142 | ||
| 93 | 143 | ## The target's centre: the middle of its solid pieces' corners. |
| 94 | 144 | func _target_centre() -> Vector3: |
| 95 | 145 | var lo := Vector3(INF, INF, INF) |
| … | … | @@ -452,14 +502,16 @@ func _depth() -> float: |
| 452 | 502 | |
| 453 | 503 | func _through() -> float: |
| 454 | 504 | var pieces: Array = [] |
| 455 | for b: int in (level.get_group_brush_ids(_target) as PackedInt32Array): | |
| 456 | var bd: Dictionary = level.get_brush(b) | |
| 457 | if not bool(bd.get("clearance", false)) and not bool(bd.get("cutter", false)): | |
| 458 | pieces.append(bd["planes"]) | |
| 459 | if pieces.is_empty(): | |
| 460 | var id: int = level.brush_by_key(_target) if level.has_method("brush_by_key") else -1 | |
| 461 | if id >= 0: | |
| 462 | pieces.append(level.get_brush(id)["planes"]) | |
| 505 | for t: String in _targets(): # through EVERY target | |
| 506 | var before := pieces.size() | |
| 507 | for b: int in (level.get_group_brush_ids(t) as PackedInt32Array): | |
| 508 | var bd: Dictionary = level.get_brush(b) | |
| 509 | if not bool(bd.get("clearance", false)) and not bool(bd.get("cutter", false)): | |
| 510 | pieces.append(bd["planes"]) | |
| 511 | if pieces.size() == before: | |
| 512 | var id: int = level.brush_by_key(t) if level.has_method("brush_by_key") else -1 | |
| 513 | if id >= 0: | |
| 514 | pieces.append(level.get_brush(id)["planes"]) | |
| 463 | 515 | return CutDepth.through(pieces, _o, _n, grid_step()) |
| 464 | 516 | |
| 465 | 517 | |
| … | … | @@ -563,7 +615,7 @@ func confirm() -> bool: |
| 563 | 615 | "size": [size.x, size.y, size.z], "material_id": _material, "level_id": _level_id}) |
| 564 | 616 | var ok := dot != "" |
| 565 | 617 | if ok: |
| 566 | var rec := {"op": mode(), "cutter": dot, "targets": [_target]} | |
| 618 | var rec := {"op": mode(), "cutter": dot, "targets": _targets()} | |
| 567 | 619 | var odot: String = map._persist.record("op.create", rec) |
| 568 | 620 | ok = odot != "" |
| 569 | 621 | if ok and not _live: |
| … | … | @@ -574,6 +626,11 @@ func confirm() -> bool: |
| 574 | 626 | var p: Dictionary = (defaults.get(kp[0], {}) as Dictionary).duplicate(true) |
| 575 | 627 | p.merge(kp[1] as Dictionary, true) |
| 576 | 628 | map._persist.record("mod.create", {"target": dot, "mod": kp[0], "params": p}) |
| 629 | # Every cutter in ONE "Cutters" collection (organisation; the wires are the | |
| 630 | # cutters overlay's, Shift+Alt+C). | |
| 631 | var col := _cutters_collection() | |
| 632 | if col != "": | |
| 633 | map._persist.record("col.link", {"target": col, "members": ["g:" + dot]}) | |
| 577 | 634 | map._persist.end_gesture() |
| 578 | 635 | if not ok: |
| 579 | 636 | print("[boxcutter] the journal refused the cut") |
| … | … | @@ -586,5 +643,6 @@ func confirm() -> bool: |
| 586 | 643 | _phase = Phase.AIM |
| 587 | 644 | _frozen = false |
| 588 | 645 | _points.clear() |
| 646 | _remember_queue() | |
| 589 | 647 | _clear_queue() |
| 590 | 648 | return true |
| … | … | @@ -39,7 +39,7 @@ func _ready() -> void: |
| 39 | 39 | await get_tree().process_frame |
| 40 | 40 | await _run() |
| 41 | 41 | get_tree().root.get_node("/root/GameMode").set_mode("play") |
| 42 | if checks < 29: | |
| 42 | if checks < 36: | |
| 43 | 43 | print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks) |
| 44 | 44 | fails += 1 |
| 45 | 45 | print("\nGATE box_cutter: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails]) |
| … | … | @@ -263,3 +263,37 @@ func _run() -> void: |
| 263 | 263 | and is_equal_approx(float(pp.get("shear_x", 0)), 0.25), pp) |
| 264 | 264 | op.set_tool_setting("taper", 1.0) |
| 265 | 265 | op.set_tool_setting("shear_x", 0.0) |
| 266 | ||
| 267 | # ---- step 6: housekeeping (§13.5) ---- | |
| 268 | # H1 the whole SELECTION is the target: two boxes selected, one rect across both. | |
| 269 | var ha := _box(Vector3(220, 0, 0), Vector3(4, 2, 4)) | |
| 270 | var hb := _box(Vector3(226, 0, 0), Vector3(4, 2, 4)) | |
| 271 | var ids := PackedInt32Array() | |
| 272 | ids.append_array(level.get_group_brush_ids(ha)) | |
| 273 | ids.append_array(level.get_group_brush_ids(hb)) | |
| 274 | map.select_brushes(ids) | |
| 275 | op.place(Vector3(221, 2, 1), Vector3.UP, Vector3(221, 2, 1), Vector3(229, 2, 3), ha) | |
| 276 | op.confirm() | |
| 277 | var tg: Array = Array(_last("op.create").get("targets", [])) | |
| 278 | _ok("H1 both selected objects are targets", tg.has(ha) and tg.has(hb), tg) | |
| 279 | _ok("H1 ... both are cut", _count(ha, Vector3(222, 1, 2)) == 0 and _count(hb, Vector3(228, 1, 2)) == 0) | |
| 280 | map.select_brushes(PackedInt32Array()) | |
| 281 | # H2 every cutter is linked into ONE collection named "Cutters". | |
| 282 | var cuts: Array = [] | |
| 283 | var tree: Dictionary = persist.collection_tree() | |
| 284 | for cd: String in (tree.get("cols", {}) as Dictionary): | |
| 285 | if String(tree["cols"][cd]["name"]) == "Cutters": | |
| 286 | cuts.append(cd) | |
| 287 | var hc := _last("shape.create") | |
| 288 | var hcd := "%s:%d" % [hc["author"], int(hc["seq"])] | |
| 289 | _ok("H2 one Cutters collection", cuts.size() == 1, cuts) | |
| 290 | _ok("H2 ... holding the last cutter", cuts.size() == 1 and (tree["cols"][cuts[0]]["members"] as Array).has("g:" + hcd), | |
| 291 | tree["cols"][cuts[0]]["members"] if cuts.size() == 1 else []) | |
| 292 | # H3 Shift+R repeats with the last cut's modifiers: the queue comes back. | |
| 293 | var tr3 := _box(Vector3(240, 0, 0), Vector3(4, 2, 4)) | |
| 294 | op.place(Vector3(241, 2, 1), Vector3.UP, Vector3(241, 2, 1), Vector3(243, 2, 3), tr3) | |
| 295 | op.handle_key(_key(KEY_2)) | |
| 296 | op.confirm() | |
| 297 | op.restore_last_queue() | |
| 298 | _ok("H3 repeat restores the last queue (mirror y)", String(op._queue_text()) == "mirror y", op._queue_text()) | |
| 299 | _ok("H3 ... and Shift+R repeats the BoxCutter", (map.get("REPEATABLE") as Array).has("builder.box_cutter")) |