extends Node
## GATE — THE CABLE GENERATOR (#120 L3, docs/node-tree-and-stacks.md §11.1, param_cable.gd).
## G1 a straight 10 m cable, round 8, max_len 1: 10 segments, each the hull of two
## octagons (16 corners, 10 planes)
## G2 neighbours share their ring exactly: watertight
## G3 a 90° bend under max_angle 10: at least 9 segments, none turning past ~10°
## G4 a square profile, tilt 45° at the end: the end ring stands on a corner
## G5 taper: radius 2 at the last point -> the end ring twice as wide
## G6 a strip: flat x the width high
## G7 connectors: one plug at each open end, 1.6 x the radius, reaching in along the curve
## G8 rings every 2.5 m on 10 m: three, at 2.5 / 5 / 7.5
## G9 a closed curve's last segment ends ON the first ring
## G10 the same input twice, the same planes
## G11 FAR FROM THE ORIGIN (300 m) a bent cable's pieces are still clean hulls of two
## rings (<= 18 planes, <= 16 corners) — float32 hulling was position-dependent
## G12 per-end caps: a plug at the start only
## G13 a flange at the end: a wide thin plate on the wall + a neck
## G14 a socket: a square box whatever the profile
## G15 Cap size / Cap length scale it
## G16 the old Connectors switch still plugs an end left at none
## G17 a closed curve has no caps
## STRANDS (§14.1, Cablerator's multi-profile):
## S1 strands = 1 ignores spread and twist: the same planes as before strands existed
## S2 3 strands on 10 m: 30 body pieces, each the hull of two octagons
## S3 each strand is watertight on its own (neighbours share 8 corners)
## S4 strand j's centre sits spread x r off the axis, 120° apart, strand 0 along n
## S5 twist 90°/m: at x = 10 strand 0 has turned 900° (= 180°), below the axis
## S6 a plug covers every strand: 1.6 x the radius x (1 + spread)
## S7 a ring covers every strand: 1.3 x the radius x (1 + spread)
## S8 a closed loop with twist: the twist rounds so every strand closes
## S9 strands are TABLED (user 2026-10-19): the Shape panel offers no Strands / Spread /
## Twist row
##
## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_gen.tscn
const Cable := preload("res://scenes/maps/shapes/param/param_cable.gd")
const ParamShapes := preload("res://scenes/maps/shapes/param/param_shapes.gd")
var fails := 0
var checks := 0
var gen: Object
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:
gen = Cable.new()
_run()
if checks < 16:
print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
fails += 1
print("\nGATE cable_gen: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
get_tree().quit(0 if fails == 0 else 1)
## A curve state through `ps` (n = 0: no wall at the ends; auto handles).
func _curve(ps: Array, closed := false, radii: Array = [], tilts: Array = []) -> Dictionary:
var pts: Array = []
for i in ps.size():
var q := CurveMath.point(ps[i], Vector3.ZERO)
if i < radii.size():
q["radius"] = float(radii[i])
if i < tilts.size():
q["tilt"] = float(tilts[i])
pts.append(q)
return {"points": CurveMath.auto_handles(pts, closed), "closed": closed}
func _pieces(curve: Dictionary, over: Dictionary = {}) -> Array:
return gen.world_pieces(curve, gen.resolve(over))
func _role(ps: Array, role: String) -> Array:
return ps.filter(func(x: Dictionary) -> bool: return String(x["role"]) == role)
func _corners(pc: Dictionary) -> PackedVector3Array:
return FaceKit.corners(pc["planes"])
func _bounds(pc: Dictionary) -> AABB:
var cs := _corners(pc)
var bb := AABB(cs[0], Vector3.ZERO)
for v: Vector3 in cs:
bb = bb.expand(v)
return bb
## Corners of `a` that are also corners of `b` (within 1e-4).
func _shared(a: PackedVector3Array, b: PackedVector3Array) -> int:
var n := 0
for v: Vector3 in a:
for u: Vector3 in b:
if v.distance_to(u) < 1.0e-4:
n += 1
break
return n
func _run() -> void:
_ok("setup: the static DCLevel.hull_of", ClassDB.class_has_method("DCLevel", "hull_of"))
var straight := _curve([Vector3(0, 0.5, 0), Vector3(10, 0.5, 0)])
# ---- G1 ----
var body := _role(_pieces(straight), "cable")
_ok("G1 10 m, max_len 1: 10 segments", body.size() == 10, body.size())
_ok("G1 ... each the hull of two octagons: 16 corners, 10 planes",
body.all(func(pc: Dictionary) -> bool: return _corners(pc).size() == 16 and (pc["planes"] as PackedFloat32Array).size() / 4 == 10),
body.map(func(pc: Dictionary) -> Array: return [_corners(pc).size(), (pc["planes"] as PackedFloat32Array).size() / 4]))
# ---- G2 ----
var tight := true
for i in range(1, body.size()):
if _shared(_corners(body[i - 1]), _corners(body[i])) != 8:
tight = false
_ok("G2 every neighbour pair shares its ring (8 corners): watertight", tight)
# ---- G3 ----
var bend := _curve([Vector3(0, 0.5, 0), Vector3(5, 0.5, 0), Vector3(5, 0.5, 5)])
var bb := _role(_pieces(bend, {"max_len": 10.0}), "cable")
_ok("G3 a 90° bend, max_angle 10, no length rule: >= 9 segments", bb.size() >= 9, bb.size())
var worst := 0.0
for pc: Dictionary in bb:
# The two ring normals: a segment's two caps; their angle is the segment's turn.
var pl: PackedFloat32Array = pc["planes"]
var caps: Array = []
var cs := _corners(pc)
for i in pl.size() / 4:
var n := Vector3(pl[i * 4], pl[i * 4 + 1], pl[i * 4 + 2])
var on := 0
for v: Vector3 in cs:
if absf(n.dot(v) - pl[i * 4 + 3]) < 1.0e-3:
on += 1
if on == 8:
caps.append(n)
if caps.size() == 2:
worst = maxf(worst, 180.0 - rad_to_deg((caps[0] as Vector3).angle_to(caps[1])))
_ok("G3 ... none turning past 10° (+ one fine step)", worst <= 11.0, worst)
# ---- G4 ----
var sq := _role(_pieces(_curve([Vector3(0, 0.5, 0), Vector3(10, 0.5, 0)], false, [], [0.0, 45.0]),
{"profile": "square", "radius": 0.1}), "cable")
# The START ring only (x = 0): the tilt ramps along the curve, so the first segment's
# far ring is already turned a little.
var first_top := -INF
for v0: Vector3 in Array(_corners(sq[0])).filter(func(v: Vector3) -> bool: return v.x < 0.001):
first_top = maxf(first_top, v0.y)
var end_cs: Array = Array(_corners(sq[sq.size() - 1])).filter(func(v: Vector3) -> bool: return v.x > 9.999)
var end_top := -INF
for v: Vector3 in end_cs:
end_top = maxf(end_top, v.y)
_ok("G4 square, untilted at the start: top at +r", absf(first_top - 0.6) < 1.0e-3, first_top)
_ok("G4 ... tilt 45° at the end: stands on a corner (+r√2)", absf(end_top - (0.5 + 0.1 * sqrt(2.0))) < 1.0e-3, end_top)
# ---- G5 ----
var tp := _role(_pieces(_curve([Vector3(0, 0.5, 0), Vector3(10, 0.5, 0)], false, [1.0, 2.0]), {"radius": 0.1}), "cable")
var e5: Array = Array(_corners(tp[tp.size() - 1])).filter(func(v: Vector3) -> bool: return v.x > 9.999)
var s5: Array = Array(_corners(tp[0])).filter(func(v: Vector3) -> bool: return v.x < 0.001)
var w := func(vs: Array) -> float:
var lo := INF
var hi := -INF
for v: Vector3 in vs:
lo = minf(lo, v.y)
hi = maxf(hi, v.y)
return hi - lo
_ok("G5 taper: the end ring twice the start's height", absf(float(w.call(e5)) - 2.0 * float(w.call(s5))) < 1.0e-3,
[w.call(s5), w.call(e5)])
# ---- G6 ----
var st := _role(_pieces(straight, {"profile": "strip", "radius": 0.1, "flat": 0.3}), "cable")
var b6 := _bounds(st[0])
_ok("G6 strip: 0.2 wide (z), 0.06 high (y)", absf(b6.size.z - 0.2) < 1.0e-3 and absf(b6.size.y - 0.06) < 1.0e-3, b6.size)
# ---- G7 ----
var con := _role(_pieces(straight, {"connectors": true, "radius": 0.1}), "connector")
_ok("G7 connectors: two plugs", con.size() == 2, con.size())
if con.size() == 2:
var c0 := _bounds(con[0])
var c1 := _bounds(con[1])
_ok("G7 ... the first from x 0 to 0.3 (3 r), 0.32 high (1.6 x 2r)", absf(c0.position.x) < 1.0e-3
and absf(c0.size.x - 0.3) < 1.0e-3 and absf(c0.size.y - 0.32) < 1.0e-3, c0)
_ok("G7 ... the second from x 9.7 to 10", absf(c1.position.x - 9.7) < 1.0e-3 and absf(c1.size.x - 0.3) < 1.0e-3, c1)
# ---- G8 ----
var rg := _role(_pieces(straight, {"ring_every": 2.5, "radius": 0.1}), "ring")
var xs: Array = rg.map(func(pc: Dictionary) -> float: return snappedf(_bounds(pc).get_center().x, 0.01))
_ok("G8 rings every 2.5 m: at 2.5 / 5 / 7.5", xs == [2.5, 5.0, 7.5], xs)
_ok("G8 ... 1.3 x the radius, 0.8 x the radius wide", rg.size() > 0 and absf(_bounds(rg[0]).size.x - 0.08) < 1.0e-3
and absf(_bounds(rg[0]).size.y - 0.26) < 1.0e-3, _bounds(rg[0]) if rg.size() > 0 else null)
# ---- G9 ----
var loop := _curve([Vector3(0, 0.5, 0), Vector3(4, 0.5, 0), Vector3(4, 0.5, 4), Vector3(0, 0.5, 4)], true)
var lb := _role(_pieces(loop), "cable")
_ok("G9 closed: the last segment ends on the first ring", lb.size() > 2
and _shared(_corners(lb[lb.size() - 1]), _corners(lb[0])) == 8, lb.size())
# ---- G10 ----
var again := _pieces(straight, {"connectors": true, "ring_every": 2.5})
var once := _pieces(straight, {"connectors": true, "ring_every": 2.5})
_ok("G10 the same input, the same planes", again.size() == once.size()
and range(again.size()).all(func(i: int) -> bool: return again[i]["planes"] == once[i]["planes"]))
# ---- G11 ----
var far := _role(_pieces(_curve([Vector3(300, 0.5, 300), Vector3(305, 0.5, 300), Vector3(305, 0.5, 305)]),
{"radius": 0.1}), "cable")
var worst_pl := 0
var worst_cs := 0
for pc: Dictionary in far:
worst_pl = maxi(worst_pl, (pc["planes"] as PackedFloat32Array).size() / 4)
worst_cs = maxi(worst_cs, _corners(pc).size())
_ok("G11 a bent cable at 300 m: every piece <= 18 planes, <= 16 corners", far.size() > 0
and worst_pl <= 18 and worst_cs <= 16, [far.size(), worst_pl, worst_cs])
# ---- G12..G17: per-end CAPS (user 2026-10-18: "set connection caps for cables") ----
# The straight cable runs x 0 -> 10 at y 0.5; r = 0.1.
var c12 := _role(_pieces(straight, {"radius": 0.1, "start_cap": "plug"}), "connector")
_ok("G12 a plug at the START only: one piece, x 0 .. 0.3, 0.32 high", c12.size() == 1
and absf(_bounds(c12[0]).position.x) < 1.0e-3 and absf(_bounds(c12[0]).size.x - 0.3) < 1.0e-3
and absf(_bounds(c12[0]).size.y - 0.32) < 1.0e-3, c12.map(_bounds))
var c13 := _role(_pieces(straight, {"radius": 0.1, "end_cap": "flange"}), "connector")
var b13: Array = c13.map(_bounds)
var plate := false
var neck := false
for b: AABB in b13:
plate = plate or (absf(b.position.x - 9.94) < 1.0e-3 and absf(b.size.y - 0.6) < 1.0e-3)
neck = neck or (absf(b.position.x - 9.8) < 1.0e-3 and absf(b.size.y - 0.26) < 1.0e-3)
_ok("G13 a FLANGE at the end: a plate (x 9.94 .. 10, 0.6 high) and a neck (x 9.8 .. 10, 0.26 high)",
c13.size() == 2 and plate and neck, b13)
var c14 := _role(_pieces(straight, {"radius": 0.1, "start_cap": "socket"}), "connector")
_ok("G14 a SOCKET: a square box (6 planes), x 0 .. 0.25, 0.44 x 0.44", c14.size() == 1
and (c14[0]["planes"] as PackedFloat32Array).size() / 4 == 6
and absf(_bounds(c14[0]).size.x - 0.25) < 1.0e-3 and absf(_bounds(c14[0]).size.y - 0.44) < 1.0e-3
and absf(_bounds(c14[0]).size.z - 0.44) < 1.0e-3, c14.map(_bounds))
var c15 := _role(_pieces(straight, {"radius": 0.1, "start_cap": "plug", "cap_size": 2.0, "cap_length": 2.0}), "connector")
_ok("G15 Cap size 2 / Cap length 2: the plug 0.64 high, 0.6 long", c15.size() == 1
and absf(_bounds(c15[0]).size.y - 0.64) < 1.0e-3 and absf(_bounds(c15[0]).size.x - 0.6) < 1.0e-3,
c15.map(_bounds))
var c16 := _role(_pieces(straight, {"radius": 0.1, "connectors": true, "start_cap": "socket"}), "connector")
var end_plug := false
for b16: AABB in c16.map(_bounds):
end_plug = end_plug or absf(b16.position.x - 9.7) < 1.0e-3
_ok("G16 the old Connectors switch still plugs an end left at none (here the end)", c16.size() == 2
and end_plug, c16.map(_bounds))
_ok("G17 a closed curve has no ends: no caps", _role(_pieces(loop, {"start_cap": "plug", "end_cap": "flange"}),
"connector").is_empty())
# ---- S1..S7: STRANDS. The straight cable's frame: t = +x, n = +y, b = +z. ----
var plain := _pieces(straight, {"radius": 0.1, "ring_every": 2.5, "end_cap": "plug"})
var one := _pieces(straight, {"radius": 0.1, "ring_every": 2.5, "end_cap": "plug",
"strands": 1, "spread": 3.0, "twist": 90.0})
_ok("S1 strands 1 ignores spread / twist: the same planes", plain.size() == one.size()
and range(plain.size()).all(func(i: int) -> bool: return plain[i]["planes"] == one[i]["planes"]),
[plain.size(), one.size()])
var s3 := _role(_pieces(straight, {"radius": 0.1, "strands": 3, "spread": 1.0}), "cable")
_ok("S2 3 strands on 10 m: 30 body pieces, each 16 corners / 10 planes", s3.size() == 30
and s3.all(func(pc: Dictionary) -> bool: return _corners(pc).size() == 16 and (pc["planes"] as PackedFloat32Array).size() / 4 == 10),
s3.size())
var tight3 := s3.size() == 30
if tight3:
for j in 3:
for i in range(1, 10):
if _shared(_corners(s3[j * 10 + i - 1]), _corners(s3[j * 10 + i])) != 8:
tight3 = false
_ok("S3 each strand watertight: its neighbours share 8 corners (strand-major order)", tight3)
var centres: Array = []
if s3.size() == 30:
for j in 3:
var c := _bounds(s3[j * 10]).get_center()
centres.append(Vector2(c.y - 0.5, c.z)) # (n, b) offset from the axis
var where := centres.size() == 3
for j in centres.size():
var want := Vector2(cos(TAU * j / 3.0), sin(TAU * j / 3.0)) * 0.1
where = where and (centres[j] as Vector2).distance_to(want) < 1.0e-3
_ok("S4 strand j at spread x r = 0.1 off the axis, at 2πj/3 from n", where, centres)
var tw := _role(_pieces(straight, {"radius": 0.1, "strands": 3, "spread": 1.0, "twist": 90.0}), "cable")
var end0: Array = Array(_corners(tw[9])).filter(func(v: Vector3) -> bool: return v.x > 9.999) \
if tw.size() == 30 else []
var mid0 := Vector3.ZERO
for v: Vector3 in end0:
mid0 += v / float(end0.size())
_ok("S5 twist 90°/m: strand 0's end ring centred 0.1 BELOW the axis (900° = 180°)", end0.size() == 8
and absf(mid0.y - 0.4) < 1.0e-3 and absf(mid0.z) < 1.0e-3, mid0)
var p6 := _role(_pieces(straight, {"radius": 0.1, "strands": 3, "spread": 1.0, "start_cap": "plug"}), "connector")
_ok("S6 the plug covers every strand: 0.64 high (1.6 x 2 x 0.1 x 2)", p6.size() == 1
and absf(_bounds(p6[0]).size.y - 0.64) < 1.0e-3, p6.map(_bounds))
var r7 := _role(_pieces(straight, {"radius": 0.1, "strands": 3, "spread": 1.0, "ring_every": 5.0}), "ring")
_ok("S7 the ring covers every strand: 0.52 high (1.3 x 2 x 0.1 x 2)", r7.size() == 1
and absf(_bounds(r7[0]).size.y - 0.52) < 1.0e-3, r7.map(_bounds))
# A closed loop, 3 strands, twist 30°/m (16.8 m: 481°, rounded to 480° = 4 x 120°): each
# strand's last piece ends on the first ring of strand j + 1. Within 1 mm, not 1e-4:
# where the frame TURNS (a loop turns everywhere) consecutive rings are not parallel,
# their side quads are skew, and dc_hull keeps one of two triangles closer than 0.8°
# (SAME_NORMAL), moving a corner — measured 0.17 mm untwisted, 0.73 mm with twist, and
# exact on a straight run. A WRONG closure misses by a strand's spacing (>= 0.1 m).
var lt := _role(_pieces(loop, {"radius": 0.1, "strands": 3, "spread": 1.0, "twist": 30.0}), "cable")
var per := lt.size() / 3
var meets: Array = []
var t0: Vector3 = CurveMath.sample(loop["points"], true, 2)[0]["t"]
if lt.size() % 3 == 0 and per > 2:
for j in 3:
var last_cs := _corners(lt[j * per + per - 1])
var row: Array = []
for m in 3:
# The worst of the first ring's 8 corners (the 8 furthest back along the
# start tangent) to the last piece's nearest corner.
var worst8 := 0.0
var near: Array = Array(_corners(lt[m * per]))
near.sort_custom(func(a: Vector3, b: Vector3) -> bool: return a.dot(t0) < b.dot(t0))
near = near.slice(0, 8)
for v: Vector3 in near:
var dmin := INF
for u: Vector3 in last_cs:
dmin = minf(dmin, v.distance_to(u))
worst8 = maxf(worst8, dmin)
row.append(snappedf(worst8, 1.0e-5) if near.size() == 8 else -1.0)
meets.append(row)
var closes := meets.size() == 3
for j in meets.size():
for m in 3:
var d: float = meets[j][m]
closes = closes and (d >= 0.0 and d < 1.0e-3 if m == (j + 1) % 3 else d > 0.05)
_ok("S8 closed + twist 480°: strand j's last piece ends on strand j+1's first ring (< 1 mm), not another's",
closes, meets)
var ids: Array = []
for r: Dictionary in ParamShapes.rows(gen, gen.resolve({})):
ids.append(String(r["id"]))
_ok("S9 strands tabled: no Strands / Spread / Twist row in the Shape panel", not ids.has("p.strands")
and not ids.has("p.spread") and not ids.has("p.twist") and ids.has("p.radius"), ids)
_cache_checks()
## THE PIECES CACHE (#127): world_pieces answers from a content-keyed cache. Each input it
## reads changes ALONE here, with the params left as they were (the case a key that missed
## an input would get wrong: a dropped curve went undetected by every cable gate), and the
## answer must equal a fresh, uncached _world_pieces of that input — and differ from before.
## C1 the same input twice: the SAME array (the cache is live, so C2–C4 test it)
## C2 the path curve's points moved
## C3 the path curve closed
## C4 the profile curve's points moved, and the profile curve closed / opened
func _cache_checks() -> void:
var a := _curve([Vector3(0, 0.5, 0), Vector3(5, 0.5, 2), Vector3(10, 0.5, 0)])
var p: Dictionary = gen.resolve({"radius": 0.1})
var first: Array = gen.world_pieces(a, p)
_ok("C1 cache: the same input twice -> the same pieces array", is_same(first, gen.world_pieces(a, p)))
var b := _curve([Vector3(0, 0.5, 0), Vector3(5, 1.5, 2), Vector3(10, 0.5, 0)])
var pb: Array = gen.world_pieces(b, p)
_ok("C2 path points moved: = uncached, != before", pb == gen._world_pieces(b, p) and pb != first, pb.size())
# The SAME points, only `closed` flipped (_curve(..., true) re-derives the handles, so
# the points would differ too and hide a key that dropped `closed`).
var c := {"points": a["points"], "closed": true}
var pc: Array = gen.world_pieces(c, p)
_ok("C3 path closed: = uncached, != before", pc == gen._world_pieces(c, p) and pc != first, pc.size())
var pcust: Dictionary = gen.resolve({"radius": 0.1, "profile": "custom"})
var sq := _curve([Vector3(100, 0, 0), Vector3(101, 0, 0), Vector3(101, 1, 0), Vector3(100, 1, 0)], true)
var kite := _curve([Vector3(100, 0, 0), Vector3(101, 0, 0), Vector3(102, 2, 0), Vector3(100, 1, 0)], true)
var sq_open := _curve([Vector3(100, 0, 0), Vector3(101, 0, 0), Vector3(101, 1, 0), Vector3(100, 1, 0)], false)
var ps: Array = gen.world_pieces(a, pcust, sq)
var pk: Array = gen.world_pieces(a, pcust, kite)
var po: Array = gen.world_pieces(a, pcust, sq_open)
_ok("C4 profile points moved: = uncached, != before", pk == gen._world_pieces(a, pcust, kite) and pk != ps)
_ok("C4 profile opened (folds round): = uncached, != before", po == gen._world_pieces(a, pcust, sq_open) and po != ps)