| … | … | @@ -1014,3 +1014,12 @@ Every command in a menu and on a key (standing rule): |
| 1014 | 1014 | refused. |
| 1015 | 1015 | 7. Measure: pieces and full-fold time for a 20-cable bundle and a 7-strand 10 m cable |
| 1016 | 1016 | (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 | |
| 1019 | rounds 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 | |
| 1021 | only. Where the frame turns, consecutive rings are not parallel, the side quads are skew, | |
| 1022 | and dc_hull's dedup (SAME_NORMAL 0.9999, about 0.8°) keeps one of each pair of triangles, | |
| 1023 | moving the shared corners: up to 0.17 mm on an untwisted bend (this was true before | |
| 1024 | strands), up to 0.73 mm with twist 30°/m. Shorter segments do not shrink it. S8 therefore | |
| 1025 | checks closure to 1 mm, against 0.17 m for a wrong closure. |
| … | … | @@ -19,6 +19,16 @@ extends "res://scenes/maps/shapes/param/param_shape.gd" |
| 19 | 19 | ## The old `connectors` switch is kept hidden: on, it plugs every end left at none, so a |
| 20 | 20 | ## cable recorded before caps folds to the same brushes. RINGS: every ring_every metres, |
| 21 | 21 | ## 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. | |
| 22 | 32 | |
| 23 | 33 | ## The fine sampling the segment walk reads: about this spacing (metres) ... |
| 24 | 34 | const FINE_STEP := 0.02 |
| … | … | @@ -68,6 +78,12 @@ func params() -> Array: |
| 68 | 78 | {"id": "connectors", "label": "Connectors", "type": "bool", "default": false, "hidden": true}, |
| 69 | 79 | {"id": "ring_every", "label": "Rings every", "type": "float", "default": 0.0, "min": 0.0, "max": 20.0, |
| 70 | 80 | "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)"}, | |
| 71 | 87 | ] |
| 72 | 88 | |
| 73 | 89 | |
| … | … | @@ -96,16 +112,39 @@ func world_pieces(curve: Dictionary, p: Dictionary) -> Array: |
| 96 | 112 | last = k |
| 97 | 113 | if cuts[cuts.size() - 1] != fs.size() - 1 and not closed: |
| 98 | 114 | 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 | |
| 109 | 148 | # ---- caps: an open curve's two ends ---- |
| 110 | 149 | if not closed: |
| 111 | 150 | var caps := [String(p["start_cap"]), String(p["end_cap"])] |
| … | … | @@ -114,7 +153,7 @@ func world_pieces(curve: Dictionary, p: Dictionary) -> Array: |
| 114 | 153 | caps[i] = "plug" |
| 115 | 154 | var e: Dictionary = fs[0] if i == 0 else fs[fs.size() - 1] |
| 116 | 155 | 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)) | |
| 118 | 157 | # ---- rings: every ring_every metres, the ends excluded ---- |
| 119 | 158 | var every := float(p["ring_every"]) |
| 120 | 159 | if every > 1.0e-6: |
| … | … | @@ -122,7 +161,7 @@ func world_pieces(curve: Dictionary, p: Dictionary) -> Array: |
| 122 | 161 | var s := every |
| 123 | 162 | while s < length - 1.0e-6: |
| 124 | 163 | 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 | |
| 126 | 165 | var half: Vector3 = (f2["t"] as Vector3) * rr2 * RING_WIDTH * 0.5 |
| 127 | 166 | var c: Vector3 = f2["p"] |
| 128 | 167 | var pl3 := FaceKit.hull_sections(_ring(c - half, f2["n"], f2["b"], rr2 * RING_SCALE, p), |
| … | … | @@ -19,6 +19,15 @@ extends Node |
| 19 | 19 | ## G15 Cap size / Cap length scale it |
| 20 | 20 | ## G16 the old Connectors switch still plugs an end left at none |
| 21 | 21 | ## 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 | |
| 22 | 31 | ## |
| 23 | 32 | ## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_cable_gen.tscn |
| 24 | 33 | |
| … | … | @@ -236,3 +245,81 @@ func _run() -> void: |
| 236 | 245 | and end_plug, c16.map(_bounds)) |
| 237 | 246 | _ok("G17 a closed curve has no ends: no caps", _role(_pieces(loop, {"start_cap": "plug", "end_cap": "flange"}), |
| 238 | 247 | "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) |