extends Node
## GATE — THE CABLE TOOLS (#120 L3, docs/node-tree-and-stacks.md §11.3).
## K1 the Add menu lists Cable; builder.add_cable starts the curve tool in cable mode
## K2 Enter with two placed points: ONE gesture, a curve and a cable shape on it
## K3 slack 1 m on a 10 m span: 3 interior points, the middle one d = sqrt(3·10·1/8)
## below the chord; 320 m out its pieces are still clean hulls (<= 18 planes)
## K4 G on a cable moves its CURVE (one curve.set), and the pieces with it
## K5 R on a cable turns its curve about the pivot
## K6 X on a cable removes its curve too; Ctrl+Z brings both back
## K7 X on a cable whose curve a follow-curve also reads keeps the curve
## K8 the Shape panel sets the caps: Start cap, End cap, Cap size, Cap length
##
## Points are handed to the tool directly: aiming is gate_curve_tool's (the same code).
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_tool.tscn
var fails := 0
var checks := 0
var map: Node3D
var persist: Node
var level: Node
var ops: 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 < 14:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE cable_tool: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
func _cables() -> Array:
return (persist.live_shapes() as Dictionary).keys().filter(func(d: String) -> bool:
return String(persist.shape_state(d)["type"]) == "cable")
func _curve_of(dot: String) -> String:
return String((persist.shape_state(dot)["params"] as Dictionary).get("curve", ""))
func _pieces_aabb(dot: String) -> AABB:
var bb := AABB()
var first := true
for b: int in (level.get_group_brush_ids(dot) as PackedInt32Array):
for c: Vector3 in FaceKit.corners(level.get_brush(b)["planes"]):
bb = AABB(c, Vector3.ZERO) if first else bb.expand(c)
first = false
return bb
func _run() -> void:
persist = map.get("_persist")
level = map.get("level")
ops = map._ensure_ops()
persist.refold()
# ---- K1 ----
var rows: Array = []
for sec: Array in map.add_sections():
for r: Array in sec[1]:
rows.append(r[0])
_ok("K1 the Add menu lists Cable", rows.has("builder.add_cable"), rows)
var ev := InputEventAction.new()
ev.action = "builder.add_cable"
ev.pressed = true
map._unhandled_input(ev)
var op: Node = map.get("_cable_op")
_ok("K1 builder.add_cable: the curve tool, in cable mode", ops.get("active") == op and bool(op.get("as_cable")),
ops.get("active"))
# ---- K2 ----
op.set("_pts", [CurveMath.point(Vector3(300, 0.5, 300), Vector3.ZERO), CurveMath.point(Vector3(310, 0.5, 300), Vector3.ZERO)])
var n0: int = (persist.records(false) as Array).size()
ops.confirm()
var cs := _cables()
_ok("K2 Enter: one cable shape", cs.size() == 1, cs)
_ok("K2 ... two records (curve + shape)", (persist.records(false) as Array).size() == n0 + 2)
var c: String = cs[0] if cs.size() == 1 else ""
_ok("K2 ... on its curve, with pieces", c != "" and persist.curve_states().has(_curve_of(c))
and (level.get_group_brush_ids(c) as PackedInt32Array).size() > 0)
# ---- K3 ----
map._unhandled_input(ev)
op.set("slack", 1.0)
op.set("_pts", [CurveMath.point(Vector3(320, 3.0, 300), Vector3.ZERO), CurveMath.point(Vector3(330, 3.0, 300), Vector3.ZERO)])
ops.confirm()
op.set("slack", 0.0)
var hung := ""
for d: String in _cables():
if d != c:
hung = d
var hp: Array = persist.curve_states().get(_curve_of(hung), {}).get("points", [])
_ok("K3 slack 1 m: 2 + 3 interior points", hp.size() == 5, hp.size())
if hp.size() == 5:
var mid: Vector3 = (hp[2] as Dictionary)["p"]
_ok("K3 ... the middle sags sqrt(3·10·1/8) = 1.936 m", absf((3.0 - mid.y) - sqrt(30.0 / 8.0)) < 1.0e-3, mid)
# 320 m out: each piece is still a clean hull of two octagons (Jolt rejected sliver hulls).
var worst := [0, 0]
for b: int in (level.get_group_brush_ids(hung) as PackedInt32Array):
var pl: PackedFloat32Array = level.get_brush(b)["planes"]
worst = [maxi(worst[0], pl.size() / 4), maxi(worst[1], FaceKit.corners(pl).size())]
_ok("K3 ... at 320 m every piece <= 18 planes, <= 16 corners", worst[0] > 0 and worst[0] <= 18
and worst[1] <= 16, worst)
# ---- K4 ----
var bb0 := _pieces_aabb(c)
var nk: int = (persist.records(false) as Array).size()
persist.begin_gesture("Move")
map.translate_units([{"kind": "shape", "shape": c, "w": 1.0}], Vector3(0, 0, 5))
persist.end_gesture()
persist.refold()
var recs: Array = persist.records(false)
_ok("K4 G on a cable: ONE curve.set, no shape.xform", recs.size() == nk + 1
and String((recs[recs.size() - 1] as Dictionary)["kind"]) == "curve.set", recs.slice(nk).map(func(r: Dictionary) -> String: return r["kind"]))
var p0: Vector3 = (persist.curve_states()[_curve_of(c)]["points"][0] as Dictionary)["p"]
_ok("K4 ... the curve moved +5 z", p0.distance_to(Vector3(300, 0.5, 305)) < 1.0e-3, p0)
_ok("K4 ... and the pieces with it", absf(_pieces_aabb(c).position.z - (bb0.position.z + 5.0)) < 1.0e-3, [bb0, _pieces_aabb(c)])
# ---- K5 ----
persist.begin_gesture("Rotate")
map.rotate_brushes(Array(level.get_group_brush_ids(c)), Vector3.UP, 90.0, Vector3(300, 0.5, 305))
persist.end_gesture()
persist.refold()
var p1: Vector3 = (persist.curve_states()[_curve_of(c)]["points"][1] as Dictionary)["p"]
_ok("K5 R 90° about the start: the end swings to -z", p1.distance_to(Vector3(300, 0.5, 295)) < 1.0e-3, p1)
# ---- K6 ----
var cd := _curve_of(c)
map.delete_brushes(Array(level.get_group_brush_ids(c)))
_ok("K6 X on the cable: the shape is gone", not (persist.live_shapes() as Dictionary).has(c))
_ok("K6 ... and its curve", not persist.curve_states().has(cd))
persist.undo_last()
persist.refold()
_ok("K6 Ctrl+Z: both back", (persist.live_shapes() as Dictionary).has(c) and persist.curve_states().has(cd))
# ---- K7 ----
var box: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
"frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(340, 0, 340))),
"size": [1.0, 1.0, 1.0], "material_id": 2, "level_id": 0})
persist.refold()
map.add_modifier("curve", box, {"curve": cd, "count": 2})
map.delete_brushes(Array(level.get_group_brush_ids(c)))
_ok("K7 a follow-curve reads the curve: X on the cable keeps it", not (persist.live_shapes() as Dictionary).has(c)
and persist.curve_states().has(cd))
# ---- K8: caps from the Shape panel (user 2026-10-18) ----
var c8: String = map.add_cable([CurveMath.point(Vector3(360, 0.5, 300)), CurveMath.point(Vector3(366, 0.5, 300))])
await get_tree().process_frame
var sp: Node = map.get("_shape_panel")
var ids8: Array = sp.setting_ids() if sp != null else []
_ok("K8 the selected cable's panel has Start cap, End cap, Cap size, Cap length",
ids8.has("p.start_cap") and ids8.has("p.end_cap") and ids8.has("p.cap_size") and ids8.has("p.cap_length"), ids8)
_ok("K8 ... and not the old Connectors switch", not ids8.has("p.connectors"), ids8)
var n8: int = (level.get_group_brush_ids(c8) as PackedInt32Array).size()
sp.apply("p.end_cap", 2) # none / plug / FLANGE / socket
persist.refold()
_ok("K8 End cap = Flange: one shape.set, two more pieces", String(persist.shape_state(c8)["params"].get("end_cap", ""))
== "flange" and (level.get_group_brush_ids(c8) as PackedInt32Array).size() == n8 + 2,
[persist.shape_state(c8)["params"].get("end_cap"), n8, (level.get_group_brush_ids(c8) as PackedInt32Array).size()])