OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps / scenes / test / gate_cable_gen.gd

367 lines · gd ref:
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)