extends Node
## GATE — THE BOXCUTTER TOOL (#120 L2 §13.2–13.4; box_cutter_operator.gd). Step 3: a rect
## drawn on a face, the modes, depth, one gesture.
## B0 the map has the operator (Alt+W starts it; keymap_gate checks the binding)
## B1 a rect on the top face, through (default): ONE gesture writes a prism shape.create
## and an op.create subtract, the cutter's dot as the op's cutter
## B2 ... the hole goes right through: air at mid-height and at the bottom, solid beside
## B3 X once = slice (BoxCutter's X): the committed op is a slice
## B4 I = inset: a pocket (default depth 0.25) — air near the top, solid below it
## B5 a set depth (2 grid steps, E's value): air above it, solid below
## B6 one Ctrl+Z removes the whole commit (shape and op): the target is whole again
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_box_cutter.tscn
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
func _ok(what: String, cond: bool, note: Variant = "") -> void:
checks += 1
if not cond:
fails += 1
print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
func _ready() -> void:
var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
gs.route("world:level:*", 1, "peer")
var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
if keet != null:
keet.current_member_id = "gate-author"
get_tree().root.get_node("/root/GameMode").set_mode("build")
map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
add_child(map)
for i in 6:
await get_tree().process_frame
await _run()
get_tree().root.get_node("/root/GameMode").set_mode("play")
if checks < 36:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE box_cutter: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _key(code: Key) -> InputEventKey:
var e := InputEventKey.new()
e.keycode = code
e.physical_keycode = code
e.pressed = true
return e
func _box(at: Vector3, size: Vector3) -> String:
var d: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, at)),
"size": [size.x, size.y, size.z], "material_id": 2, "level_id": 0})
persist.refold()
return d
func _count(group: String, p: Vector3) -> int:
var n := 0
for b: int in (level.get_group_brush_ids(group) as PackedInt32Array):
var d: Dictionary = level.get_brush(b)
if bool(d.get("clearance", false)) or bool(d.get("cutter", false)):
continue
var pl: PackedFloat32Array = d["planes"]
var inside := true
for i in pl.size() / 4:
if Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2]).dot(p) > pl[i * 4 + 3] + 1.0e-4:
inside = false
break
if inside:
n += 1
return n
func _last(kind: String) -> Dictionary:
var best := {}
for r: Dictionary in (persist.records(false) as Array):
if String(r.get("kind", "")) == kind and (best.is_empty() or int(r["seq"]) > int(best["seq"])):
best = r
return best
func _n(kind: String) -> int:
# Live records only: an undone one stays in the journal, flagged.
return (persist.records(false) as Array).filter(func(r: Dictionary) -> bool: return String(r.get("kind", "")) == kind and not bool(r.get("undone", false))).size()
## Draw the 2 x 2 rect (x 1..3, z 1..3) on top of a target box (x 0..4, y 0..2, z 0..4).
func _draw(op: Node, t: String) -> void:
op.place(Vector3(1, 2, 1), Vector3.UP, Vector3(1, 2, 1), Vector3(3, 2, 3), t)
func _run() -> void:
persist = map.get("_persist")
level = map.get("level")
persist.refold()
map._ensure_ops()
var op: Node = map.get_node_or_null("BoxCutter")
# ---- B0 ----
_ok("B0 the map has the BoxCutter operator", op != null and op.has_method("place"))
if op == null:
return
# ---- B1 / B2 ----
var t1 := _box(Vector3(0, 0, 0), Vector3(4, 2, 4))
var ns := _n("shape.create")
var no := _n("op.create")
_draw(op, t1)
var ok1: bool = op.confirm()
var sc := _last("shape.create")
var oc := _last("op.create")
_ok("B1 one commit writes a prism shape and a subtract op", ok1 and _n("shape.create") == ns + 1 and _n("op.create") == no + 1
and String(sc.get("type", "")) == "prism" and String(oc.get("op", "")) == "subtract", [sc.get("type"), oc.get("op")])
_ok("B1 ... the op cuts the target with the new cutter", String(oc.get("cutter", "")) == "%s:%d" % [sc["author"], int(sc["seq"])]
and Array(oc.get("targets", [])) == [t1], [oc.get("cutter"), oc.get("targets")])
_ok("B2 through: air at mid-height under the rect", _count(t1, Vector3(2, 1, 2)) == 0)
_ok("B2 ... and at the very bottom", _count(t1, Vector3(2, 0.05, 2)) == 0)
_ok("B2 ... solid beside it, once", _count(t1, Vector3(0.5, 1, 0.5)) == 1)
# ---- B6 (on the first commit) ----
persist.undo_last()
persist.refold()
_ok("B6 one Ctrl+Z removes the whole commit", _n("shape.create") == ns and _n("op.create") == no, [_n("shape.create") - ns, _n("op.create") - no])
_ok("B6 ... the target is whole again", _count(t1, Vector3(2, 1, 2)) == 1)
# ---- B3 ----
var t3 := _box(Vector3(20, 0, 0), Vector3(4, 2, 4))
op.place(Vector3(21, 2, 1), Vector3.UP, Vector3(21, 2, 1), Vector3(23, 2, 3), t3)
op.handle_key(_key(KEY_X))
_ok("B3 X once = slice", String(op.mode()) == "slice", op.mode())
op.confirm()
_ok("B3 ... the committed op is a slice", String(_last("op.create").get("op", "")) == "slice")
# ---- B4 ----
var t4 := _box(Vector3(40, 0, 0), Vector3(4, 2, 4))
op.place(Vector3(41, 2, 1), Vector3.UP, Vector3(41, 2, 1), Vector3(43, 2, 3), t4)
op.handle_key(_key(KEY_I))
op.confirm()
_ok("B4 I = inset: air in the top 0.25 under the rect", String(_last("op.create").get("op", "")) == "inset"
and _count(t4, Vector3(42, 1.9, 2)) == 0, [_last("op.create").get("op"), _count(t4, Vector3(42, 1.9, 2))])
_ok("B4 ... solid below the pocket", _count(t4, Vector3(42, 1.5, 2)) == 1)
# ---- B5 ----
var t5 := _box(Vector3(60, 0, 0), Vector3(4, 2, 4))
op.place(Vector3(61, 2, 1), Vector3.UP, Vector3(61, 2, 1), Vector3(63, 2, 3), t5)
op.handle_key(_key(KEY_X)) # the mode is sticky: inset (B4) -> outset
op.handle_key(_key(KEY_X)) # -> back round to subtract
op.set_tool_setting("depth", 2)
var g: float = op.grid_step()
op.confirm()
_ok("B5 depth 2 steps: air one and a half steps down", String(_last("op.create").get("op", "")) == "subtract"
and _count(t5, Vector3(62, 2 - 1.5 * g, 2)) == 0, [_last("op.create").get("op"), g])
_ok("B5 ... solid two and a half steps down", _count(t5, Vector3(62, 2 - 2.5 * g, 2)) == 1)
# ---- step 4: footprints and surfaces (§13.2) ----
# B7 circle: P sets the CENTRE, the aim the radius (1 here); a 24-segment footprint.
var t7 := _box(Vector3(80, 0, 0), Vector3(4, 2, 4))
op.set_tool_setting("depth", 0) # the depth is sticky (B5 set 2 steps): through again
op.set_tool_setting("shape", "circle")
op.place(Vector3(82, 2, 2), Vector3.UP, Vector3(82, 2, 2), Vector3(83, 2, 2), t7)
op.confirm()
var poly7: Array = (_last("shape.create").get("params", {}) as Dictionary).get("poly", [])
_ok("B7 circle: a 24-point footprint", poly7.size() == 24, poly7.size())
_ok("B7 ... the centre is cut through", _count(t7, Vector3(82, 1, 2)) == 0)
_ok("B7 ... the rect's corner (outside the circle) is not", _count(t7, Vector3(81.1, 1, 1.1)) == 1)
# B8 ngon: 6 sides.
var t8 := _box(Vector3(90, 0, 0), Vector3(4, 2, 4))
op.set_tool_setting("shape", "ngon")
op.set_tool_setting("sides", 6)
op.place(Vector3(92, 2, 2), Vector3.UP, Vector3(92, 2, 2), Vector3(93, 2, 2), t8)
op.confirm()
var poly8: Array = (_last("shape.create").get("params", {}) as Dictionary).get("poly", [])
_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())
# B9 custom polygon: a triangle from three P points.
var t9 := _box(Vector3(100, 0, 0), Vector3(4, 2, 4))
op.set_tool_setting("shape", "custom")
op.place_points(Vector3(101, 2, 1), Vector3.UP, [Vector3(101, 2, 1), Vector3(103, 2, 1), Vector3(101, 2, 3)], t9)
op.confirm()
_ok("B9 custom triangle: inside it is cut", _count(t9, Vector3(101.4, 1, 1.4)) == 0)
_ok("B9 ... past its long side is not", _count(t9, Vector3(102.6, 1, 2.6)) == 1)
# B10 surfaces: the plane each one draws on.
var cur: Node3D = map.get("_cursor3d")
cur.global_position = Vector3(120, 3, 0)
op.set_tool_setting("surface", "grid")
var pg: Array = op.surface_plane()
_ok("B10 grid: the world's level plane at the cursor's height", (pg[1] as Vector3).is_equal_approx(Vector3.UP)
and is_equal_approx((pg[0] as Vector3).y, 3.0), pg)
op.set_tool_setting("surface", "cursor")
var pc: Array = op.surface_plane()
_ok("B10 cursor: the cursor's own plane (its up)", (pc[1] as Vector3).is_equal_approx(cur.global_basis.y.normalized())
and (pc[0] as Vector3).is_equal_approx(cur.global_position), pc)
op.set_tool_setting("surface", "face")
op.set_tool_setting("shape", "box")
# B11 D opens the tool's menu: the four shapes and the four surfaces.
var q: Node = map.get("_q_menu")
op.handle_key(_key(KEY_D))
var rows: Array = []
var menu: PopupMenu = q.get("_menu")
for i in menu.item_count:
rows.append(menu.get_item_text(i))
_ok("B11 D opens the menu with the shapes and surfaces", q.is_open() and str(rows).contains("Circle")
and str(rows).contains("Custom") and str(rows).contains("Grid") and str(rows).contains("View"), rows)
menu.hide()
# ---- step 5: modifiers while drawing (§13.4, BoxCutter's keys) ----
# M1 1 = mirror X across the TARGET's centre: a hole at x 140.5..141.5 gets its twin at
# 142.5..143.5 (target x 140..144, centre 142); between them stays solid.
var tm := _box(Vector3(140, 0, 0), Vector3(4, 2, 4))
op.place(Vector3(140.5, 2, 1), Vector3.UP, Vector3(140.5, 2, 1), Vector3(141.5, 2, 3), tm)
op.handle_key(_key(KEY_1))
op.confirm()
var cm := _last("shape.create")
var cdot := "%s:%d" % [cm["author"], int(cm["seq"])]
_ok("M1 1 queues a mirror on the cutter", String(_last("mod.create").get("target", "")) == cdot
and String(_last("mod.create").get("mod", "")) == "mirror", [_last("mod.create").get("target"), _last("mod.create").get("mod")])
_ok("M1 ... the original hole", _count(tm, Vector3(141, 1, 2)) == 0)
_ok("M1 ... its mirror twin across the target's centre", _count(tm, Vector3(143, 1, 2)) == 0)
_ok("M1 ... and solid between them", _count(tm, Vector3(142, 1, 2)) == 1)
# M2 the queue empties after a commit; B and V V queue chamfer and radial; the same key
# again (B) takes it off.
var tq := _box(Vector3(160, 0, 0), Vector3(4, 2, 4))
op.place(Vector3(161, 2, 1), Vector3.UP, Vector3(161, 2, 1), Vector3(163, 2, 3), tq)
var nm := _n("mod.create")
op.handle_key(_key(KEY_B))
op.handle_key(_key(KEY_B)) # off again
op.handle_key(_key(KEY_V))
op.handle_key(_key(KEY_V)) # V V = radial
op.confirm()
# Only THIS cutter's modifiers (the window must not reach M1's mirror).
var cq := _last("shape.create")
var cqd := "%s:%d" % [cq["author"], int(cq["seq"])]
var kinds: Array = []
for r: Dictionary in (persist.records(false) as Array):
if String(r.get("kind", "")) == "mod.create" and String(r.get("target", "")) == cqd:
kinds.append(String(r.get("mod", "")))
_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)
# M3 Shift+G = grid; L = the cut starts OFF (the target uncut, the op there).
var tl := _box(Vector3(180, 0, 0), Vector3(4, 2, 4))
op.place(Vector3(181, 2, 1), Vector3.UP, Vector3(181, 2, 1), Vector3(183, 2, 3), tl)
var sg := _key(KEY_G)
sg.shift_pressed = true
op.handle_key(sg)
op.handle_key(_key(KEY_L))
op.confirm()
_ok("M3 Shift+G queues a grid", String(_last("mod.create").get("mod", "")) == "grid")
_ok("M3 L: the cut is written OFF — the target is uncut", _count(tl, Vector3(182, 1, 2)) == 1
and String(_last("op.create").get("op", "")) == "subtract")
# M4 taper and wedge (the D menu's values) ride in the prism's params.
var tt := _box(Vector3(200, 0, 0), Vector3(4, 2, 4))
op.set_tool_setting("taper", 0.5)
op.set_tool_setting("shear_x", 0.25)
op.place(Vector3(201, 2, 1), Vector3.UP, Vector3(201, 2, 1), Vector3(203, 2, 3), tt)
op.confirm()
var pp: Dictionary = _last("shape.create").get("params", {})
_ok("M4 taper 0.5 and shear 0.25 are the prism's params", is_equal_approx(float(pp.get("taper", 0)), 0.5)
and is_equal_approx(float(pp.get("shear_x", 0)), 0.25), pp)
op.set_tool_setting("taper", 1.0)
op.set_tool_setting("shear_x", 0.0)
# ---- step 6: housekeeping (§13.5) ----
# H1 the whole SELECTION is the target: two boxes selected, one rect across both.
var ha := _box(Vector3(220, 0, 0), Vector3(4, 2, 4))
var hb := _box(Vector3(226, 0, 0), Vector3(4, 2, 4))
var ids := PackedInt32Array()
ids.append_array(level.get_group_brush_ids(ha))
ids.append_array(level.get_group_brush_ids(hb))
map.select_brushes(ids)
op.place(Vector3(221, 2, 1), Vector3.UP, Vector3(221, 2, 1), Vector3(229, 2, 3), ha)
op.confirm()
var tg: Array = Array(_last("op.create").get("targets", []))
_ok("H1 both selected objects are targets", tg.has(ha) and tg.has(hb), tg)
_ok("H1 ... both are cut", _count(ha, Vector3(222, 1, 2)) == 0 and _count(hb, Vector3(228, 1, 2)) == 0)
map.select_brushes(PackedInt32Array())
# H2 every cutter is linked into ONE collection named "Cutters".
var cuts: Array = []
var tree: Dictionary = persist.collection_tree()
for cd: String in (tree.get("cols", {}) as Dictionary):
if String(tree["cols"][cd]["name"]) == "Cutters":
cuts.append(cd)
var hc := _last("shape.create")
var hcd := "%s:%d" % [hc["author"], int(hc["seq"])]
_ok("H2 one Cutters collection", cuts.size() == 1, cuts)
_ok("H2 ... holding the last cutter", cuts.size() == 1 and (tree["cols"][cuts[0]]["members"] as Array).has("g:" + hcd),
tree["cols"][cuts[0]]["members"] if cuts.size() == 1 else [])
# H3 Shift+R repeats with the last cut's modifiers: the queue comes back.
var tr3 := _box(Vector3(240, 0, 0), Vector3(4, 2, 4))
op.place(Vector3(241, 2, 1), Vector3.UP, Vector3(241, 2, 1), Vector3(243, 2, 3), tr3)
op.handle_key(_key(KEY_2))
op.confirm()
op.restore_last_queue()
_ok("H3 repeat restores the last queue (mirror y)", String(op._queue_text()) == "mirror y", op._queue_text())
_ok("H3 ... and Shift+R repeats the BoxCutter", (map.get("REPEATABLE") as Array).has("builder.box_cutter"))