OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / f097eb6

#120 L3 §14.1: cable strands / spread / twist (Cablerator multi-profile)
Each strand is its own convex hull per segment, strand-major; strands = 1 folds byte-identical to before. A closed cable's twist rounds to whole 2π/strands steps so every strand closes. Caps and rings wrap the bundle. gate_cable_gen S1–S8 (32/32); seam deviation on bends measured and documented. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
f097eb6a76816f3ffbd673f576bbb9713d1bbba7
by plankatron <fleeky@gmail.com> · 2026-10-19T13:55:23.000Z

Changed files (3) +147 -12

docs/node-tree-and-stacks.md +9 -0
……@@ -1014,3 +1014,12 @@ Every command in a menu and on a key (standing rule):
10141014 refused.
101510157. Measure: pieces and full-fold time for a 20-cable bundle and a 7-strand 10 m cable
10161016 (the §8 perf item: 500 stacked objects ≈ 1.7 s per edit).
1017
1018**Status 2026-10-19:** step 1 done (`gate_cable_gen` S1–S8, 32/32). A closed cable's twist
1019rounds to whole 2π / strands steps over the loop, so strand j closes onto strand j + m.
1020**Seams, measured:** §11.1's "neighbours share ring k exactly" holds on a straight run
1021only. Where the frame turns, consecutive rings are not parallel, the side quads are skew,
1022and dc_hull's dedup (SAME_NORMAL 0.9999, about 0.8°) keeps one of each pair of triangles,
1023moving the shared corners: up to 0.17 mm on an untwisted bend (this was true before
1024strands), up to 0.73 mm with twist 30°/m. Shorter segments do not shrink it. S8 therefore
1025checks closure to 1 mm, against 0.17 m for a wrong closure.
scenes/maps/shapes/param/param_cable.gd +51 -12
……@@ -19,6 +19,16 @@ extends "res://scenes/maps/shapes/param/param_shape.gd"
1919## The old `connectors` switch is kept hidden: on, it plugs every end left at none, so a
2020## cable recorded before caps folds to the same brushes. RINGS: every ring_every metres,
2121## a ring 1.3 x the radius, 0.8 x the radius wide.
22##
23## STRANDS (§14.1, Cablerator's multi-profile): `strands` copies of the profile around
24## the axis, strand j's centre spread x r off it at 2πj / strands + twist x s (a rope).
25## Each strand is its own hull per segment, strand-major in the output. strands = 1 takes
26## no offset and no twist, so a cable recorded before strands folds to the same brushes.
27## A closed cable's twist is rounded so strand j meets strand j + m after a whole loop.
28## Caps and rings wrap the whole bundle: their radius is r x (1 + spread) when strands > 1.
29## SEAMS: neighbours share their ring exactly on a straight run. Where the frame turns,
30## the rings are not parallel, the side quads are skew, and dc_hull keeps one of two
31## triangles closer than 0.8° — moving a corner by up to ~0.2 mm, ~0.7 mm with twist.
2232
2333## The fine sampling the segment walk reads: about this spacing (metres) ...
2434const FINE_STEP := 0.02
……@@ -68,6 +78,12 @@ func params() -> Array:
6878 {"id": "connectors", "label": "Connectors", "type": "bool", "default": false, "hidden": true},
6979 {"id": "ring_every", "label": "Rings every", "type": "float", "default": 0.0, "min": 0.0, "max": 20.0,
7080 "step": 0.1, "unit": "m", "tip": "0 = none"},
81 {"id": "strands", "label": "Strands", "type": "int", "default": 1, "min": 1, "max": 12, "step": 1,
82 "tip": "Copies of the profile around the axis"},
83 {"id": "spread", "label": "Spread", "type": "float", "default": 1.0, "min": 0.0, "max": 8.0,
84 "step": 0.05, "tip": "Strands only: each strand's centre, x the radius off the axis"},
85 {"id": "twist", "label": "Twist", "type": "float", "default": 0.0, "min": -720.0, "max": 720.0,
86 "step": 5.0, "unit": "°/m", "tip": "Strands only: how far they turn per metre (a rope)"},
7187 ]
7288
7389
……@@ -96,16 +112,39 @@ func world_pieces(curve: Dictionary, p: Dictionary) -> Array:
96112 last = k
97113 if cuts[cuts.size() - 1] != fs.size() - 1 and not closed:
98114 cuts.append(fs.size() - 1)
99 var rings: Array = []
100 for k: int in cuts:
101 var f: Dictionary = fs[k]
102 rings.append(_ring(f["p"], f["n"], f["b"], r0 * float(f["radius"]), p))
103 if closed:
104 rings.append(rings[0]) # the closing segment ends ON ring 0
105 for i in range(1, rings.size()):
106 var pl := FaceKit.hull_sections(rings[i - 1], rings[i])
107 if not pl.is_empty():
108 out.append({"planes": pl, "role": "cable"})
115 var strands := int(p["strands"])
116 var spread := float(p["spread"]) if strands > 1 else 0.0
117 var twist := deg_to_rad(float(p["twist"])) if strands > 1 else 0.0
118 if closed and strands > 1:
119 # Whole steps of 2π / strands over the loop, so strand j meets strand j + m.
120 var step := TAU / float(strands)
121 twist = roundf(twist * length / step) * step / length
122 for j in strands:
123 var rings: Array = []
124 for k: int in cuts:
125 var f: Dictionary = fs[k]
126 var rr := r0 * float(f["radius"])
127 var c: Vector3 = f["p"]
128 if strands > 1:
129 var a := TAU * float(j) / float(strands) + twist * float(f["s"])
130 c += ((f["n"] as Vector3) * cos(a) + (f["b"] as Vector3) * sin(a)) * spread * rr
131 rings.append(_ring(c, f["n"], f["b"], rr, p))
132 if closed:
133 if strands > 1 and absf(twist) > 1.0e-12:
134 # The closing ring: ring 0's frame, turned by the whole loop's twist.
135 var f0: Dictionary = fs[0]
136 var r00 := r0 * float(f0["radius"])
137 var a0 := TAU * float(j) / float(strands) + twist * length
138 rings.append(_ring((f0["p"] as Vector3) + ((f0["n"] as Vector3) * cos(a0)
139 + (f0["b"] as Vector3) * sin(a0)) * spread * r00, f0["n"], f0["b"], r00, p))
140 else:
141 rings.append(rings[0]) # the closing segment ends ON ring 0
142 for i in range(1, rings.size()):
143 var pl := FaceKit.hull_sections(rings[i - 1], rings[i])
144 if not pl.is_empty():
145 out.append({"planes": pl, "role": "cable"})
146 # Caps and rings wrap every strand.
147 var wrap := 1.0 + spread if strands > 1 else 1.0
109148 # ---- caps: an open curve's two ends ----
110149 if not closed:
111150 var caps := [String(p["start_cap"]), String(p["end_cap"])]
……@@ -114,7 +153,7 @@ func world_pieces(curve: Dictionary, p: Dictionary) -> Array:
114153 caps[i] = "plug"
115154 var e: Dictionary = fs[0] if i == 0 else fs[fs.size() - 1]
116155 var inward: Vector3 = e["t"] if i == 0 else -(e["t"] as Vector3)
117 out.append_array(_cap(caps[i], e, inward, r0 * float(e["radius"]), p))
156 out.append_array(_cap(caps[i], e, inward, r0 * float(e["radius"]) * wrap, p))
118157 # ---- rings: every ring_every metres, the ends excluded ----
119158 var every := float(p["ring_every"])
120159 if every > 1.0e-6:
……@@ -122,7 +161,7 @@ func world_pieces(curve: Dictionary, p: Dictionary) -> Array:
122161 var s := every
123162 while s < length - 1.0e-6:
124163 var f2: Dictionary = fs[clampi(roundi(s / length * steps), 0, fs.size() - 1)]
125 var rr2 := r0 * float(f2["radius"])
164 var rr2 := r0 * float(f2["radius"]) * wrap
126165 var half: Vector3 = (f2["t"] as Vector3) * rr2 * RING_WIDTH * 0.5
127166 var c: Vector3 = f2["p"]
128167 var pl3 := FaceKit.hull_sections(_ring(c - half, f2["n"], f2["b"], rr2 * RING_SCALE, p),
scenes/test/gate_cable_gen.gd +87 -0
……@@ -19,6 +19,15 @@ extends Node
1919## G15 Cap size / Cap length scale it
2020## G16 the old Connectors switch still plugs an end left at none
2121## G17 a closed curve has no caps
22## STRANDS (§14.1, Cablerator's multi-profile):
23## S1 strands = 1 ignores spread and twist: the same planes as before strands existed
24## S2 3 strands on 10 m: 30 body pieces, each the hull of two octagons
25## S3 each strand is watertight on its own (neighbours share 8 corners)
26## S4 strand j's centre sits spread x r off the axis, 120° apart, strand 0 along n
27## S5 twist 90°/m: at x = 10 strand 0 has turned 900° (= 180°), below the axis
28## S6 a plug covers every strand: 1.6 x the radius x (1 + spread)
29## S7 a ring covers every strand: 1.3 x the radius x (1 + spread)
30## S8 a closed loop with twist: the twist rounds so every strand closes
2231##
2332## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_gen.tscn
2433
……@@ -236,3 +245,81 @@ func _run() -> void:
236245 and end_plug, c16.map(_bounds))
237246 _ok("G17 a closed curve has no ends: no caps", _role(_pieces(loop, {"start_cap": "plug", "end_cap": "flange"}),
238247 "connector").is_empty())
248
249 # ---- S1..S7: STRANDS. The straight cable's frame: t = +x, n = +y, b = +z. ----
250 var plain := _pieces(straight, {"radius": 0.1, "ring_every": 2.5, "end_cap": "plug"})
251 var one := _pieces(straight, {"radius": 0.1, "ring_every": 2.5, "end_cap": "plug",
252 "strands": 1, "spread": 3.0, "twist": 90.0})
253 _ok("S1 strands 1 ignores spread / twist: the same planes", plain.size() == one.size()
254 and range(plain.size()).all(func(i: int) -> bool: return plain[i]["planes"] == one[i]["planes"]),
255 [plain.size(), one.size()])
256 var s3 := _role(_pieces(straight, {"radius": 0.1, "strands": 3, "spread": 1.0}), "cable")
257 _ok("S2 3 strands on 10 m: 30 body pieces, each 16 corners / 10 planes", s3.size() == 30
258 and s3.all(func(pc: Dictionary) -> bool: return _corners(pc).size() == 16 and (pc["planes"] as PackedFloat32Array).size() / 4 == 10),
259 s3.size())
260 var tight3 := s3.size() == 30
261 if tight3:
262 for j in 3:
263 for i in range(1, 10):
264 if _shared(_corners(s3[j * 10 + i - 1]), _corners(s3[j * 10 + i])) != 8:
265 tight3 = false
266 _ok("S3 each strand watertight: its neighbours share 8 corners (strand-major order)", tight3)
267 var centres: Array = []
268 if s3.size() == 30:
269 for j in 3:
270 var c := _bounds(s3[j * 10]).get_center()
271 centres.append(Vector2(c.y - 0.5, c.z)) # (n, b) offset from the axis
272 var where := centres.size() == 3
273 for j in centres.size():
274 var want := Vector2(cos(TAU * j / 3.0), sin(TAU * j / 3.0)) * 0.1
275 where = where and (centres[j] as Vector2).distance_to(want) < 1.0e-3
276 _ok("S4 strand j at spread x r = 0.1 off the axis, at 2πj/3 from n", where, centres)
277 var tw := _role(_pieces(straight, {"radius": 0.1, "strands": 3, "spread": 1.0, "twist": 90.0}), "cable")
278 var end0: Array = Array(_corners(tw[9])).filter(func(v: Vector3) -> bool: return v.x > 9.999) \
279 if tw.size() == 30 else []
280 var mid0 := Vector3.ZERO
281 for v: Vector3 in end0:
282 mid0 += v / float(end0.size())
283 _ok("S5 twist 90°/m: strand 0's end ring centred 0.1 BELOW the axis (900° = 180°)", end0.size() == 8
284 and absf(mid0.y - 0.4) < 1.0e-3 and absf(mid0.z) < 1.0e-3, mid0)
285 var p6 := _role(_pieces(straight, {"radius": 0.1, "strands": 3, "spread": 1.0, "start_cap": "plug"}), "connector")
286 _ok("S6 the plug covers every strand: 0.64 high (1.6 x 2 x 0.1 x 2)", p6.size() == 1
287 and absf(_bounds(p6[0]).size.y - 0.64) < 1.0e-3, p6.map(_bounds))
288 var r7 := _role(_pieces(straight, {"radius": 0.1, "strands": 3, "spread": 1.0, "ring_every": 5.0}), "ring")
289 _ok("S7 the ring covers every strand: 0.52 high (1.3 x 2 x 0.1 x 2)", r7.size() == 1
290 and absf(_bounds(r7[0]).size.y - 0.52) < 1.0e-3, r7.map(_bounds))
291 # A closed loop, 3 strands, twist 30°/m (16.8 m: 481°, rounded to 480° = 4 x 120°): each
292 # strand's last piece ends on the first ring of strand j + 1. Within 1 mm, not 1e-4:
293 # where the frame TURNS (a loop turns everywhere) consecutive rings are not parallel,
294 # their side quads are skew, and dc_hull keeps one of two triangles closer than 0.8°
295 # (SAME_NORMAL), moving a corner — measured 0.17 mm untwisted, 0.73 mm with twist, and
296 # exact on a straight run. A WRONG closure misses by a strand's spacing (>= 0.1 m).
297 var lt := _role(_pieces(loop, {"radius": 0.1, "strands": 3, "spread": 1.0, "twist": 30.0}), "cable")
298 var per := lt.size() / 3
299 var meets: Array = []
300 var t0: Vector3 = CurveMath.sample(loop["points"], true, 2)[0]["t"]
301 if lt.size() % 3 == 0 and per > 2:
302 for j in 3:
303 var last_cs := _corners(lt[j * per + per - 1])
304 var row: Array = []
305 for m in 3:
306 # The worst of the first ring's 8 corners (the 8 furthest back along the
307 # start tangent) to the last piece's nearest corner.
308 var worst8 := 0.0
309 var near: Array = Array(_corners(lt[m * per]))
310 near.sort_custom(func(a: Vector3, b: Vector3) -> bool: return a.dot(t0) < b.dot(t0))
311 near = near.slice(0, 8)
312 for v: Vector3 in near:
313 var dmin := INF
314 for u: Vector3 in last_cs:
315 dmin = minf(dmin, v.distance_to(u))
316 worst8 = maxf(worst8, dmin)
317 row.append(snappedf(worst8, 1.0e-5) if near.size() == 8 else -1.0)
318 meets.append(row)
319 var closes := meets.size() == 3
320 for j in meets.size():
321 for m in 3:
322 var d: float = meets[j][m]
323 closes = closes and (d >= 0.0 and d < 1.0e-3 if m == (j + 1) % 3 else d > 0.05)
324 _ok("S8 closed + twist 480°: strand j's last piece ends on strand j+1's first ring (< 1 mm), not another's",
325 closes, meets)