@protoboardai/ext-harness-cad 0.0.0-stage → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,773 @@
1
+ """Routed wire-harness CAD (protoboard ext-harness-cad).
2
+
3
+ python harness_cad.py route <job.json> <out-dir>
4
+ python harness_cad.py export <out-dir> <route.json>... [--no-step]
5
+ python harness_cad.py <job.json> <out-dir> [--no-step]
6
+
7
+ The job (lib/job.ts) holds the assembled model's bodies and, per harness,
8
+ its conductors, modelled length and ends resolved to world positions.
9
+
10
+ `route` finds each harness's route through the model and writes
11
+ <id>.route.json: the conductor paths (full precision), centreline, length
12
+ fit, bend limits, clearance and notes. That is the native design. Harnesses
13
+ are routed in the system's order (rigid ends first, then thickest); each
14
+ routed cable becomes an obstacle for the next. Only the job's `targets`
15
+ are written; harnesses before the last target are routed again in memory,
16
+ so a route does not depend on which harnesses were generated when.
17
+
18
+ `export` builds the bodies from route files, in assembly-root coordinates
19
+ (mm, Z up):
20
+
21
+ <id>.glb conductors (twisted or parallel), plug housings,
22
+ solder joints and tinned tips; one named node each
23
+ <id>.step the same solids (build123d sweeps), when they build
24
+ <id>.manifest.json end frames, lengths, bend radii, clearance, route
25
+
26
+ With a job and a folder alone it does both for every harness. Each mode
27
+ prints one JSON summary line. Outputs are deterministic for a fixed job and
28
+ build123d version: the STEP timestamp is normalised. OpenCascade numbers a
29
+ STEP file's assembly occurrences with a counter that runs for the whole
30
+ process, so the STEP of the n-th harness exported in one process differs in
31
+ those ids (not in geometry) from exporting it alone; lib/route.ts exports one
32
+ harness per process.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ import hashlib
37
+ import json
38
+ import math
39
+ import re
40
+ import sys
41
+ import time
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+
46
+ HERE = Path(__file__).resolve().parent
47
+ sys.path.insert(0, str(HERE))
48
+
49
+ from glb import read_glb, to_cad_mm, write_glb # noqa: E402
50
+ from route import ObstacleMap, apex_loop, arclen, astar, fillet, fit_length, length, min_radius, pull_string, resample, rmf, smooth # noqa: E402
51
+
52
+ TOOL = "protoboard ext-harness-cad (py/harness_cad.py)"
53
+ ROUTE_SCHEMA = "protoboard.harness-cad.route/v0"
54
+ FIXED_TIME = "2000-01-01T00:00:00"
55
+ SIDES = 10
56
+
57
+
58
+ def rnd(x, n=2):
59
+ """JSON-safe rounding (None for inf/nan)."""
60
+ return round(float(x), n) if x is not None and math.isfinite(x) else None
61
+
62
+
63
+ def unit(v):
64
+ v = np.asarray(v, dtype=float)
65
+ n = np.linalg.norm(v)
66
+ return v / n if n > 1e-12 else v
67
+
68
+
69
+ def core_diameter(awg: float | None, od: float) -> float:
70
+ if awg:
71
+ return 0.127 * 92 ** ((36 - awg) / 39)
72
+ return od
73
+
74
+
75
+ # ---------------------------------------------------------------------------------------------
76
+ # Meshing
77
+ # ---------------------------------------------------------------------------------------------
78
+
79
+
80
+ def tube(pts: np.ndarray, r: float, caps: bool = True):
81
+ pts = resample(pts, 0.7) if len(pts) > 1 else pts
82
+ t, n, b = rmf(pts, np.array([0, 0, 1.0]))
83
+ ang = np.linspace(0, 2 * math.pi, SIDES, endpoint=False)
84
+ ring = np.cos(ang)[None, :, None] * n[:, None, :] + np.sin(ang)[None, :, None] * b[:, None, :]
85
+ v = (pts[:, None, :] + r * ring).reshape(-1, 3)
86
+ faces = []
87
+ m = len(pts)
88
+ for i in range(m - 1):
89
+ for j in range(SIDES):
90
+ a = i * SIDES + j
91
+ bb = i * SIDES + (j + 1) % SIDES
92
+ c = (i + 1) * SIDES + j
93
+ d = (i + 1) * SIDES + (j + 1) % SIDES
94
+ faces += [(a, c, bb), (bb, c, d)]
95
+ if caps:
96
+ v = np.vstack([v, pts[0], pts[-1]])
97
+ c0, c1 = len(v) - 2, len(v) - 1
98
+ for j in range(SIDES):
99
+ faces.append((c0, (j + 1) % SIDES, j))
100
+ faces.append((c1, (m - 1) * SIDES + j, (m - 1) * SIDES + (j + 1) % SIDES))
101
+ return v, np.array(faces)
102
+
103
+
104
+ def box(centre, x, y, z, size):
105
+ x, y, z = unit(x), unit(y), unit(z)
106
+ hx, hy, hz = (s / 2 for s in size)
107
+ corners = np.array([centre + sx * hx * x + sy * hy * y + sz * hz * z for sx in (-1, 1) for sy in (-1, 1) for sz in (-1, 1)])
108
+ f = [(0, 1, 3), (0, 3, 2), (4, 6, 7), (4, 7, 5), (0, 4, 5), (0, 5, 1), (2, 3, 7), (2, 7, 6), (0, 2, 6), (0, 6, 4), (1, 5, 7), (1, 7, 3)]
109
+ return corners, np.array(f)
110
+
111
+
112
+ def ellipsoid(centre, axes, radii, n=10):
113
+ u = np.linspace(0, math.pi, n)
114
+ w = np.linspace(0, 2 * math.pi, 2 * n, endpoint=False)
115
+ v = []
116
+ for a in u:
117
+ for c in w:
118
+ p = np.array([math.sin(a) * math.cos(c) * radii[0], math.sin(a) * math.sin(c) * radii[1], math.cos(a) * radii[2]])
119
+ v.append(centre + p[0] * axes[0] + p[1] * axes[1] + p[2] * axes[2])
120
+ f = []
121
+ for i in range(n - 1):
122
+ for j in range(2 * n):
123
+ a = i * 2 * n + j
124
+ b = i * 2 * n + (j + 1) % (2 * n)
125
+ c = (i + 1) * 2 * n + j
126
+ d = (i + 1) * 2 * n + (j + 1) % (2 * n)
127
+ f += [(a, c, b), (b, c, d)]
128
+ return np.array(v), np.array(f)
129
+
130
+
131
+ def area_centroid(parts):
132
+ tot, acc = 0.0, np.zeros(3)
133
+ for v, f in parts:
134
+ tri = v[f]
135
+ a = 0.5 * np.linalg.norm(np.cross(tri[:, 1] - tri[:, 0], tri[:, 2] - tri[:, 0]), axis=1)
136
+ tot += a.sum()
137
+ acc += (tri.mean(axis=1) * a[:, None]).sum(axis=0)
138
+ return tot, acc / max(tot, 1e-12)
139
+
140
+
141
+ # ---------------------------------------------------------------------------------------------
142
+ # One harness
143
+ # ---------------------------------------------------------------------------------------------
144
+
145
+
146
+ def open_direction(om, p, n, reach: float, toward=None):
147
+ """The direction a wire leaves a pad in: the pad normal, or tilted up to 70 degrees
148
+ toward whichever side has the most room over the next `reach` mm (ties broken
149
+ toward the cable's other end)."""
150
+ a1 = unit(np.cross(n, [0, 0, 1.0]) if abs(n[2]) < 0.9 else np.cross(n, [1.0, 0, 0]))
151
+ a2 = np.cross(n, a1)
152
+ best = (-np.inf, n)
153
+ for tilt in (0, 30, 50, 70):
154
+ for az in (range(0, 360, 45) if tilt else [0]):
155
+ t, a = math.radians(tilt), math.radians(az)
156
+ d = unit(math.cos(t) * n + math.sin(t) * (math.cos(a) * a1 + math.sin(a) * a2))
157
+ ray = p + np.outer(np.linspace(1.5, reach, 8), d)
158
+ room = float(om.clearance(ray).min())
159
+ score = room + (0.6 * float(np.dot(d, toward)) if toward is not None else 0.0) - 0.004 * tilt
160
+ if score > best[0]:
161
+ best = (score, d)
162
+ return best[1]
163
+
164
+
165
+ def end_geometry(end, od_bundle, om=None, r_route=0.0, toward=None):
166
+ """Where an end's length is measured from (R), where its bundle starts (S), and each conductor's way out.
167
+
168
+ Plug: the conductors leave the housing's rear along the socket's normal.
169
+ Solder pads: each wire leaves its pad up and tilted toward open space
170
+ (away from the next board above, from components beside it), so a row of
171
+ pads along a board edge is left from the outside; the bundle starts
172
+ where those wires meet, pushed further out until the bundle fits there.
173
+ Integral leads (motor, capacitor): straight on from where they leave the part.
174
+ """
175
+ O = np.array(end["counterpartFrame"]["origin"], dtype=float)
176
+ n = unit(end["counterpartFrame"]["normal"])
177
+ t = end["termination"]
178
+ pins = [np.array(p["at"], dtype=float) for p in end["pins"]]
179
+ if t["kind"] == "plug":
180
+ R = O + n * t["housing"]["visible"]
181
+ stub = 3.0 + od_bundle / 2
182
+ dirs = [n for _ in pins]
183
+ S = R + n * stub
184
+ elif t["kind"] == "solder":
185
+ R = np.mean(pins, axis=0)
186
+ dirs, exits = [], []
187
+ for p, pin in zip(pins, end["pins"]):
188
+ pn = unit(pin["out"])
189
+ d = open_direction(om, p, pn, 3.0 + od_bundle, toward) if om is not None else pn
190
+ dirs.append(d)
191
+ exits.append(p + d * (3.0 + od_bundle / 2))
192
+ S = np.mean(exits, axis=0)
193
+ if om is not None:
194
+ for _ in range(24):
195
+ if om.clearance([S])[0] >= r_route + 0.3:
196
+ break
197
+ g = om.gradient(S)
198
+ if np.linalg.norm(g) < 1e-6:
199
+ break
200
+ S = S + g * 0.5
201
+ stub = float(np.linalg.norm(S - R))
202
+ else:
203
+ R = O
204
+ stub = 2.0 + od_bundle / 2
205
+ dirs = [n for _ in pins]
206
+ S = R + n * stub
207
+ exit_dir = unit(S - R)
208
+ return O, exit_dir, R, S, stub, dirs
209
+
210
+
211
+ def route_harness(h, om: ObstacleMap, notes: list[str]):
212
+ cond = h["conductors"]
213
+ nc = len(cond)
214
+ od = cond[0]["od"]
215
+ twisted = h["build"]["bundle"] == "twisted" and nc > 1
216
+ if twisted:
217
+ rb = (od / 2) / math.sin(math.pi / nc) if nc > 1 else 0.0
218
+ r_bundle = rb + od / 2
219
+ elif cond[0]["bare"]:
220
+ r_bundle = od / 2 + 0.3 # separate component leads: each finds its own way beside the route
221
+ rb = 0.0
222
+ else:
223
+ spacing = h["build"].get("spacing_mm") or od * 1.02
224
+ # a flat cable lies flat where it is tight (see ribbon_axis): route it with a little
225
+ # over half its half-width, and let the conductor check confirm it fits
226
+ r_bundle = max(od / 2, 0.6 * ((nc - 1) * spacing + od) / 2)
227
+ rb = 0.0
228
+ r_route = r_bundle + 0.3
229
+ ea, eb = h["ends"]
230
+ ca = np.mean([p["at"] for p in ea["pins"]], axis=0)
231
+ cb = np.mean([p["at"] for p in eb["pins"]], axis=0)
232
+ Oa, na, Ra, Sa, stub_a, dirs_a = end_geometry(ea, 2 * r_bundle, om, r_route, unit(cb - ca))
233
+ Ob, nb, Rb, Sb, stub_b, dirs_b = end_geometry(eb, 2 * r_bundle, om, r_route, unit(ca - cb))
234
+ # where the bundle may touch what it starts from: around each end's bundle start
235
+ zones = [(Sa, r_route + 2.5), (Sb, r_route + 2.5)]
236
+ # every conductor's own termination: where it meets its pad or plug by design
237
+ # (at a solder pad, other wires soldered to the same pad are expected within a few mm)
238
+ pin_zones = [(np.array(p["at"], dtype=float), od + (3.5 if e["termination"]["kind"] == "solder" else 2.5) + (e["termination"]["housing"]["visible"] if e["termination"]["kind"] == "plug" else 0.0)) for e in (ea, eb) for p in e["pins"]]
239
+
240
+ t0 = time.time()
241
+ path = astar(om, Sa, Sb, r_route, zones)
242
+ if path is None:
243
+ raise RuntimeError(f"{h['path']}: no route between {ea['counterpart']} and {eb['counterpart']} for a {2 * r_bundle:.1f} mm bundle")
244
+ od_target = 2 * r_bundle
245
+ # a twisted bundle bends like a round cable of its own diameter; a flat
246
+ # (parallel) one bends across its thin side, like one conductor
247
+ stiff = max(od, 0.6 * od_target) if twisted else od
248
+ # fine-strand silicone wire in a static installation: bends down to about
249
+ # 2.5 x its diameter are acceptable, 4 x is comfortable (a representative rule)
250
+ bend_target = max(4 * stiff, 4.0)
251
+ bend_min = max(2.5 * stiff, 2.5)
252
+ fixed = max(2, int(min(stub_a, stub_b) / 0.5))
253
+
254
+ def shape(halves):
255
+ poly = [Ra]
256
+ for k, hp in enumerate(halves):
257
+ seg = [Sa if k == 0 else hp[0]] + hp[1:-1] + [Sb if k == len(halves) - 1 else hp[-1]]
258
+ pulled = pull_string(om, seg, r_route, zones)
259
+ poly += pulled if k == 0 else pulled[1:]
260
+ poly.append(Rb)
261
+ c, rr = fillet(poly, bend_target)
262
+ return smooth(c, om, r_route, fixed=fixed, iters=150, zones=zones), rr, poly
263
+
264
+ centre, radii, poly = shape([[Sa] + path[1:-1] + [Sb]])
265
+ target = h["length_mm"]
266
+ base = length(centre)
267
+ fit = {"route_mm": round(base, 2)}
268
+ method = "shortest"
269
+ if target > base + max(3.0, 0.08 * base):
270
+ # more than a little slack: a service loop pulled out into clear space
271
+ got = apex_loop(poly, om, r_route, zones, target, bend_target, bend_min, fixed, skip=max(stub_a, stub_b) + 2.0)
272
+ if got is not None:
273
+ centre, info = got
274
+ method = "service loop"
275
+ fit.update(info)
276
+ t_search = time.time() - t0
277
+ base2 = length(centre)
278
+ if abs(target - length(centre)) <= (1.0 if method != "shortest" else 1.5):
279
+ fit.setdefault("method", method) # within tolerance of the modelled length already
280
+ elif target < base - 0.5:
281
+ notes.append(f"modelled length {target} mm is {base - target:.1f} mm shorter than the shortest clear route ({base:.1f} mm): the cable does not reach")
282
+ fit["method"] = "too short"
283
+ else:
284
+ keep = min(max(stub_a, stub_b) + 2 * od_target, 0.3 * base2)
285
+ centre, info = fit_length(centre, target, om, r_route, keep, zones, bend_min, bend_target)
286
+ info["method"] = method if info["method"] == "none" else (info["method"] if method == "shortest" else f"{method} + {info['method']}")
287
+ fit.update(info)
288
+ if "not clear" in info["method"] or "failed" in info["method"]:
289
+ notes.append(f"slack could not be laid clear: {info['method']} ({info.get('worst_clearance_mm', '?')} mm worst clearance, {info.get('bend_mm', '?')} mm bend radius)")
290
+ centre = resample(centre, 0.5)
291
+ centre = fair(centre, om, r_route, zones, fixed=max(4, int(min(stub_a, stub_b) / 0.5)))
292
+ return {
293
+ "centre": centre,
294
+ "r_bundle": r_bundle,
295
+ "rb": rb,
296
+ "twisted": twisted,
297
+ "ends": [(Oa, na, Ra, Sa, stub_a), (Ob, nb, Rb, Sb, stub_b)],
298
+ "dirs": [dirs_a, dirs_b],
299
+ "bend_target": bend_target,
300
+ "bend_min": bend_min,
301
+ "fillet_radii": radii,
302
+ "fit": fit,
303
+ "zones": zones,
304
+ "pin_zones": pin_zones,
305
+ "t_search": t_search,
306
+ }
307
+
308
+
309
+ def ribbon_axis(centre, t, om, across_a, across_b, fan):
310
+ """Width direction of a flat cable along its route: flat against the nearest surface
311
+ (normal to both the route and the direction away from that surface), turning to
312
+ each end's pin row over the fan length."""
313
+ n = len(centre)
314
+ s = arclen(centre)
315
+ w = np.zeros_like(centre)
316
+ prev = None
317
+ for i in range(n):
318
+ g = om.gradient(centre[i])
319
+ c = np.cross(t[i], g)
320
+ if np.linalg.norm(c) < 0.2:
321
+ c = prev if prev is not None else np.cross(t[i], [0, 0, 1.0])
322
+ c = c - t[i] * np.dot(c, t[i])
323
+ c /= max(np.linalg.norm(c), 1e-9)
324
+ if prev is not None and np.dot(c, prev) < 0:
325
+ c = -c
326
+ w[i] = c
327
+ prev = c
328
+ # smooth, then blend to the pin rows at the ends
329
+ k = 9
330
+ ker = np.ones(k) / k
331
+ w = np.stack([np.convolve(np.pad(w[:, j], k // 2, mode="edge"), ker, mode="valid") for j in range(3)], axis=1)
332
+ for i in range(n):
333
+ a = min(1.0, s[i] / max(fan, 1e-6))
334
+ b = min(1.0, (s[-1] - s[i]) / max(fan, 1e-6))
335
+ target = w[i]
336
+ for end_w, f in ((across_a, a), (across_b, b)):
337
+ ew = end_w - t[i] * np.dot(end_w, t[i])
338
+ if np.linalg.norm(ew) > 1e-6:
339
+ ew /= np.linalg.norm(ew)
340
+ if np.dot(ew, target) < 0:
341
+ ew = -ew
342
+ target = f * target + (1 - f) * ew
343
+ target -= t[i] * np.dot(target, t[i])
344
+ w[i] = target / max(np.linalg.norm(target), 1e-9)
345
+ return w
346
+
347
+
348
+ def conductor_paths(h, r, om=None):
349
+ """Each conductor from its termination on end A to its termination on end B."""
350
+ centre = r["centre"]
351
+ cond = h["conductors"]
352
+ nc = len(cond)
353
+ od = cond[0]["od"]
354
+ ea, eb = h["ends"]
355
+ (Oa, na, Ra, Sa, stub_a), (Ob, nb, Rb, Sb, stub_b) = r["ends"]
356
+ # frame along the centreline, oriented by end A's pin order
357
+ pins_a = [np.array(p["at"], dtype=float) for p in ea["pins"]]
358
+ pins_b = [np.array(p["at"], dtype=float) for p in eb["pins"]]
359
+ across = pins_a[-1] - pins_a[0] if nc > 1 else np.array([0, 0, 1.0])
360
+ t, nrm, bin_ = rmf(centre, across)
361
+ s = arclen(centre)
362
+ total = s[-1]
363
+ pitch = h["build"].get("twist_pitch_mm") or 20.0
364
+ spread = max(np.ptp(np.array(pins_a), axis=0).max(), np.ptp(np.array(pins_b), axis=0).max()) if nc > 1 else 0.0
365
+ fan = min(max(6.0, 3.0 * r["r_bundle"] * 2, 1.2 * spread), 0.35 * total)
366
+
367
+ if not r["twisted"] and nc > 1 and om is not None:
368
+ across_b = pins_b[h["pairs"][-1]] - pins_b[h["pairs"][0]]
369
+ nrm = ribbon_axis(centre, t, om, across, across_b, fan)
370
+
371
+ def offset(k, i):
372
+ if r["twisted"]:
373
+ a = 2 * math.pi * k / nc + 2 * math.pi * s[i] / pitch
374
+ return r["rb"] * (math.cos(a) * nrm[i] + math.sin(a) * bin_[i])
375
+ return (k - (nc - 1) / 2) * (h["build"].get("spacing_mm") or od * 1.02) * nrm[i]
376
+
377
+ def term(end, pin, n):
378
+ kind = end["termination"]["kind"]
379
+ if kind == "plug":
380
+ return pin + n * end["termination"]["housing"]["visible"]
381
+ if kind == "solder":
382
+ return pin + n * (od / 2)
383
+ return pin
384
+
385
+ out = []
386
+ for k in range(nc):
387
+ kb = h["pairs"][k]
388
+ ta = term(ea, pins_a[k], unit(ea["pins"][k]["out"]))
389
+ tb = term(eb, pins_b[kb], unit(eb["pins"][kb]["out"]))
390
+ # na / nb are the end's exit directions (tilted off a pad toward open space)
391
+ ia = int(np.searchsorted(s, fan))
392
+ ib = int(np.searchsorted(s, total - fan))
393
+ body = np.array([centre[i] + offset(k, i) for i in range(ia, ib + 1)])
394
+ # A side: stub along the pin's outward normal, then a cubic into the bundle
395
+ da = r["dirs"][0][k]
396
+ db = r["dirs"][1][kb]
397
+ pa1 = ta + da * max(1.0, 0.85 * min(stub_a, 3.0 + 2 * r["r_bundle"]))
398
+ qa = body[0]
399
+ ha = np.linalg.norm(qa - pa1) / 2.2
400
+ ba = np.array([(1 - u) ** 3 * pa1 + 3 * (1 - u) ** 2 * u * (pa1 + da * ha) + 3 * (1 - u) * u**2 * (qa - t[ia] * ha) + u**3 * qa for u in np.linspace(0, 1, 16)])
401
+ pb1 = tb + db * max(1.0, 0.85 * min(stub_b, 3.0 + 2 * r["r_bundle"]))
402
+ qb = body[-1]
403
+ hb = np.linalg.norm(qb - pb1) / 2.2
404
+ bb = np.array([(1 - u) ** 3 * qb + 3 * (1 - u) ** 2 * u * (qb + t[ib] * hb) + 3 * (1 - u) * u**2 * (pb1 + db * hb) + u**3 * pb1 for u in np.linspace(0, 1, 16)])
405
+ path = np.vstack([ta, ba, body[1:-1], bb, tb])
406
+ out.append(resample(path, 0.4))
407
+ return out
408
+
409
+
410
+ def fair(centre: np.ndarray, om: ObstacleMap, radius: float, zones, fixed: int) -> np.ndarray:
411
+ """Fair the centre line with a smoothing spline (keeping the ends and their stubs),
412
+ removing the small kinks that obstacle-bounded smoothing leaves; kept only if it stays clear."""
413
+ from scipy.interpolate import splev, splprep
414
+
415
+ if len(centre) < 2 * fixed + 6:
416
+ return centre
417
+ mid = centre[fixed:-fixed]
418
+ try:
419
+ for tol in (0.08, 0.05, 0.03):
420
+ tck, _ = splprep(mid.T, s=len(mid) * tol**2, k=3)
421
+ u = np.linspace(0, 1, len(mid))
422
+ new = np.array(splev(u, tck)).T
423
+ new[0], new[-1] = mid[0], mid[-1]
424
+ c = om.clearance(new)
425
+ inside = np.array([any(np.linalg.norm(q - zc) <= zr for zc, zr in zones) for q in new])
426
+ if ((c >= radius - 0.35) | inside).all(): # the conductors are relaxed clear afterwards
427
+ return np.vstack([centre[:fixed], new, centre[-fixed:]])
428
+ except Exception: # noqa: BLE001
429
+ pass
430
+ return centre
431
+
432
+
433
+ def relax(p: np.ndarray, om: ObstacleMap, radius: float, zones, iters: int = 150) -> np.ndarray:
434
+ """Push a conductor's points out of solids (along the clearance gradient), smoothing as it goes.
435
+
436
+ The route is found for the bundle's centre line; the conductors fanned
437
+ and twisted around it can graze a corner. This settles each one the last
438
+ fraction of a millimetre clear, the way a real wire would lie.
439
+ """
440
+ q = p.copy()
441
+ free = np.array([not any(np.linalg.norm(x - zc) <= zr for zc, zr in zones) for x in q])
442
+ free[:2] = free[-2:] = False
443
+ for _ in range(iters):
444
+ c = om.clearance(q)
445
+ bad = free & (c < radius + 0.05)
446
+ if not bad.any():
447
+ break
448
+ for i in np.where(bad)[0]:
449
+ g = om.gradient(q[i])
450
+ if np.linalg.norm(g) > 1e-9:
451
+ q[i] = q[i] + g * (radius + 0.15 - c[i])
452
+ if _ % 4:
453
+ continue
454
+ # keep it smooth where it moved
455
+ sm = q.copy()
456
+ sm[1:-1] = 0.5 * q[1:-1] + 0.25 * (q[:-2] + q[2:])
457
+ near = np.convolve(bad.astype(float), np.ones(7), mode="same") > 0
458
+ near &= free
459
+ q[near] = sm[near]
460
+ return q
461
+
462
+
463
+
464
+ # ---------------------------------------------------------------------------------------------
465
+ # Routing (the native design) and export (the bodies)
466
+ # ---------------------------------------------------------------------------------------------
467
+
468
+
469
+ def routing_order(harnesses: list[dict]) -> list[dict]:
470
+ """The system's routing order: rigid ends first (plug housings, component leads), which
471
+ cannot move out of the way; then thickest first. Soldered flying leads bend around what
472
+ is already there."""
473
+ rigid = lambda h: sum(e["termination"]["kind"] == "plug" for e in h["ends"]) + (2 if h["conductors"][0]["bare"] else 0)
474
+ return sorted(harnesses, key=lambda h: (-rigid(h), -h["conductors"][0]["od"] * len(h["conductors"])))
475
+
476
+
477
+ def obstacle_map(job) -> ObstacleMap:
478
+ """Every placed body, in a lattice that covers the bodies and every harness end in the job."""
479
+ meshes = []
480
+ lo = np.array([np.inf] * 3)
481
+ hi = -lo
482
+ for b in job["bodies"]:
483
+ m = np.array(b["matrix"], dtype=float).reshape(4, 4)
484
+ for _name, v, f in read_glb(b["glb"]):
485
+ w = to_cad_mm(v) @ m[:3, :3].T + m[:3, 3]
486
+ meshes.append((w, f, b["key"]))
487
+ lo = np.minimum(lo, w.min(axis=0))
488
+ hi = np.maximum(hi, w.max(axis=0))
489
+ for h in job["harnesses"]:
490
+ for e in h["ends"]:
491
+ for p in e["pins"]:
492
+ lo = np.minimum(lo, np.array(p["at"]) - 40)
493
+ hi = np.maximum(hi, np.array(p["at"]) + 40)
494
+ # a lattice anchored at whole millimetres, so routes do not depend on where the model's bounds fall
495
+ om = ObstacleMap(np.floor(lo - 15), np.ceil(hi + 15), 1.0)
496
+ for w, f, key in meshes:
497
+ om.add_solid(w, f, key)
498
+ om.finalize()
499
+ return om
500
+
501
+
502
+ def route_one(h, om: ObstacleMap) -> tuple[dict, float]:
503
+ """Route one harness through the map and stamp it as an obstacle for later routes.
504
+
505
+ Returns the route (conductor paths at full precision, centreline, fit, bend limits,
506
+ clearance, notes) as plain JSON data, and the search time."""
507
+ notes: list[str] = []
508
+ r = route_harness(h, om, notes)
509
+ paths = conductor_paths(h, r, om)
510
+ od = h["conductors"][0]["od"]
511
+ paths = [relax(p, om, od / 2, r["pin_zones"]) for p in paths]
512
+
513
+ # ---- clearance of every conductor (outside the terminations' zones) ----
514
+ worst = math.inf
515
+ worst_at = None
516
+ for p in paths:
517
+ c = om.clearance(p) - od / 2
518
+ mask = np.ones(len(p), dtype=bool)
519
+ for zc, zr in r["pin_zones"]:
520
+ mask &= np.linalg.norm(p - zc, axis=1) > zr
521
+ if mask.any():
522
+ i = int(np.argmin(np.where(mask, c, np.inf)))
523
+ if c[i] < worst:
524
+ worst, worst_at = float(c[i]), p[i]
525
+ if worst < -0.2:
526
+ notes.append(f"a conductor comes within {worst:.2f} mm of a solid (penetrates {om.owner_at(worst_at)}) at {np.round(worst_at, 1).tolist()}")
527
+
528
+ # ---- stamp as an obstacle for later routes ----
529
+ for p in paths:
530
+ om.stamp_tube(p, od / 2, h["path"])
531
+ om.finalize()
532
+
533
+ route = {
534
+ "schema": ROUTE_SCHEMA,
535
+ "tool": TOOL,
536
+ "harness": h,
537
+ "paths": [p.tolist() for p in paths],
538
+ "centre": np.asarray(r["centre"]).tolist(),
539
+ "stubs": [float(r["ends"][0][4]), float(r["ends"][1][4])],
540
+ "twisted": bool(r["twisted"]),
541
+ "r_bundle": float(r["r_bundle"]),
542
+ "bend_target": float(r["bend_target"]),
543
+ "bend_min": float(r["bend_min"]),
544
+ "fit": r["fit"],
545
+ "clearance_mm": worst if math.isfinite(worst) else None,
546
+ "notes": notes,
547
+ }
548
+ # exactly what a route file reads back as, so routing and exporting in one process
549
+ # gives the same bodies as exporting a written route
550
+ return json.loads(json.dumps(route)), r["t_search"]
551
+
552
+
553
+ def export_route(route: dict, out_dir: Path, step_ok: bool) -> dict:
554
+ """Write <id>.glb, <id>.step and <id>.manifest.json from a route."""
555
+ h = route["harness"]
556
+ paths = [np.array(p, dtype=float) for p in route["paths"]]
557
+ centre = np.array(route["centre"], dtype=float)
558
+ notes = list(route["notes"])
559
+ cond = h["conductors"]
560
+ od = cond[0]["od"]
561
+ awg = h.get("awg")
562
+ core_d = core_diameter(awg, od) if not cond[0]["bare"] else od
563
+ strip = h["build"].get("strip_mm", 2.0)
564
+ ea, eb = h["ends"]
565
+
566
+ # ---- meshes ----
567
+ conductors, terminations = [], {ea["interfaceId"]: [], eb["interfaceId"]: []}
568
+ for k, (c, p) in enumerate(zip(cond, paths)):
569
+ s = arclen(p)
570
+ colour = c["color"]
571
+ if c["bare"]:
572
+ v, f = tube(p, od / 2)
573
+ conductors.append({"name": f"conductor_{c['position']}", "vertices": v, "faces": f, "color": "#c9ccd1", "metallic": 0.85, "roughness": 0.35, "material": "tinned copper"})
574
+ continue
575
+ lo = strip if ea["termination"]["kind"] == "solder" else 0.0
576
+ hi = s[-1] - (strip if eb["termination"]["kind"] == "solder" else 0.0)
577
+ ins = p[(s >= lo) & (s <= hi)]
578
+ v, f = tube(ins, od / 2)
579
+ conductors.append({"name": f"conductor_{c['position']}", "vertices": v, "faces": f, "color": colour, "metallic": 0.0, "roughness": 0.55, "material": "silicone insulation"})
580
+ for end, sel in ((ea, s <= lo + 0.2), (eb, s >= hi - 0.2)):
581
+ if end["termination"]["kind"] != "solder" or sel.sum() < 2:
582
+ continue
583
+ v, f = tube(p[sel], core_d / 2 * 1.08)
584
+ terminations[end["interfaceId"]].append({"name": f"{end['interfaceId']}_tip_{c['position']}", "vertices": v, "faces": f, "color": "#c9ccd1", "metallic": 0.85, "roughness": 0.3, "material": "tinned copper"})
585
+ for end in (ea, eb):
586
+ t = end["termination"]
587
+ n = unit(end["counterpartFrame"]["normal"])
588
+ if t["kind"] == "plug":
589
+ O = np.array(end["counterpartFrame"]["origin"], dtype=float)
590
+ x = unit(end["counterpartFrame"].get("xAxis") or np.cross(n, [0, 0, 1.0]))
591
+ y = np.cross(n, x)
592
+ hsg = t["housing"]
593
+ v, f = box(O + n * (hsg["visible"] - hsg["depth"] / 2), x, y, n, (hsg["width"], hsg["height"], hsg["depth"]))
594
+ terminations[end["interfaceId"]].append({"name": f"{end['interfaceId']}_housing", "vertices": v, "faces": f, "color": hsg["color"], "metallic": 0.0, "roughness": 0.5, "material": hsg["name"]})
595
+ elif t["kind"] == "solder":
596
+ for pin in end["pins"]:
597
+ P = np.array(pin["at"], dtype=float)
598
+ nn = unit(pin["out"])
599
+ a1 = unit(np.cross(nn, [0, 0, 1.0]) if abs(nn[2]) < 0.9 else np.cross(nn, [1.0, 0, 0]))
600
+ a2 = np.cross(nn, a1)
601
+ rj = max(core_d * 1.1, 0.9)
602
+ v, f = ellipsoid(P + nn * 0.25, (a1, a2, nn), (rj, rj, max(0.55, core_d * 0.6)))
603
+ terminations[end["interfaceId"]].append({"name": f"{end['interfaceId']}_joint_{pin['position']}", "vertices": v, "faces": f, "color": "#b9bdc4", "metallic": 0.9, "roughness": 0.25, "material": "solder"})
604
+ groups = {"conductors": conductors, **{k: v for k, v in terminations.items() if v}}
605
+ out_dir.mkdir(parents=True, exist_ok=True)
606
+ glb = out_dir / f"{h['id']}.glb"
607
+ write_glb(glb, groups)
608
+
609
+ # ---- STEP (build123d sweeps) ----
610
+ step_status = "skipped"
611
+ if step_ok:
612
+ try:
613
+ step_status = write_step(out_dir / f"{h['id']}.step", h, paths, od, groups)
614
+ except Exception as e: # noqa: BLE001
615
+ step_status = f"failed: {type(e).__name__}: {e}"
616
+ notes.append(f"STEP not written ({step_status})")
617
+
618
+ # ---- manifest ----
619
+ lengths = [round(length(p), 2) for p in paths]
620
+ # bends at the terminations (a wire turning off its pad or out of its plug) are reported apart
621
+ term = max(route["stubs"]) + 1.0
622
+ bend_centre = min_radius(centre, 1.5, skip=term)
623
+ bend_term = min_radius(centre, 1.0, skip=0.5)
624
+ bend_cond = min(min_radius(p, 1.5, skip=term) for p in paths)
625
+ if math.isfinite(bend_centre) and bend_centre < route["bend_min"] - 0.05:
626
+ notes.append(f"centreline bend radius {bend_centre:.1f} mm is under the {route['bend_min']:.1f} mm minimum")
627
+ features = {}
628
+ for iface, meshes in terminations.items():
629
+ if meshes:
630
+ a, c = area_centroid([(m["vertices"], m["faces"]) for m in meshes])
631
+ features[iface] = {"area_mm2": round(a, 3), "centroid": [round(x, 3) + 0.0 for x in c], "faces": len(meshes)}
632
+ fit = route["fit"]
633
+ manifest = {
634
+ "artifact": h["id"],
635
+ "tool": TOOL,
636
+ "toolVersion": "0.1.0",
637
+ "sourceDigest": hashlib.sha256((json.dumps(h, sort_keys=True) + (HERE / "harness_cad.py").read_text() + (HERE / "route.py").read_text()).encode()).hexdigest(),
638
+ "units": "mm",
639
+ "coordinates": "assembly root (mm, Z up); glTF export in m, Y up",
640
+ "harness": h["path"],
641
+ "length_mm": h["length_mm"],
642
+ "route_length_mm": round(float(length(centre)), 2),
643
+ "conductor_lengths_mm": lengths,
644
+ "length_fit": fit,
645
+ "bundle": {"kind": "twisted" if route["twisted"] else "parallel", "conductors": len(cond), "conductor_od_mm": od, "bundle_od_mm": round(2 * route["r_bundle"], 2), "twist_pitch_mm": h["build"].get("twist_pitch_mm") if route["twisted"] else None},
646
+ "bend_radius_mm": {"target": rnd(route["bend_target"]), "minimum": rnd(route["bend_min"]), "centreline": rnd(bend_centre), "conductors": rnd(bend_cond), "at_terminations": rnd(bend_term)},
647
+ "clearance_mm": {"min_conductor_surface": rnd(route["clearance_mm"])},
648
+ "ends": {e["interfaceId"]: {"frame": e["frame"], "counterpart": e["counterpart"], "termination": e["termination"]["kind"], "link": e["linkId"]} for e in (ea, eb)},
649
+ "features": features,
650
+ "step": step_status,
651
+ "route": [[round(x, 2) for x in p] for p in resample(centre, 2.0)],
652
+ "status": "ok" if not notes else "warning",
653
+ "notes": notes,
654
+ }
655
+ (out_dir / f"{h['id']}.manifest.json").write_text(json.dumps(manifest, indent=2) + "\n")
656
+ return {"id": h["id"], "path": h["path"], "status": manifest["status"], "notes": notes, "length_mm": h["length_mm"], "route_length_mm": manifest["route_length_mm"], "slack_mm": fit.get("slack_mm"), "bend_mm": manifest["bend_radius_mm"], "clearance_mm": manifest["clearance_mm"]["min_conductor_surface"], "step": step_status}
657
+
658
+
659
+ def write_step(path: Path, h, paths, od, groups) -> str:
660
+ import build123d as bd
661
+
662
+ solids = []
663
+ for c, p in zip(h["conductors"], paths):
664
+ # control points: enough to follow a twist, few enough to keep the STEP small
665
+ q = resample(p, 3.0 if h["build"]["bundle"] == "twisted" else 6.0)
666
+ q[0], q[-1] = p[0], p[-1]
667
+ try:
668
+ spline = bd.Spline(*[bd.Vector(*x) for x in q])
669
+ prof = bd.Plane(origin=bd.Vector(*q[0]), z_dir=spline.tangent_at(0)) * bd.Circle(od / 2)
670
+ s = bd.sweep(prof.faces()[0] if hasattr(prof, "faces") else prof, path=spline)
671
+ valid = s.is_valid() if callable(s.is_valid) else s.is_valid
672
+ if not valid:
673
+ raise ValueError("invalid sweep")
674
+ except Exception: # noqa: BLE001
675
+ # a sweep that self-intersects in a tight twist: a chain of short cylinders instead
676
+ q = resample(p, 1.2)
677
+ segs = []
678
+ for a, b in zip(q[:-1], q[1:]):
679
+ v = b - a
680
+ ln = float(np.linalg.norm(v))
681
+ if ln < 1e-6:
682
+ continue
683
+ cyl = bd.Cylinder(od / 2, ln + od * 0.3, align=(bd.Align.CENTER, bd.Align.CENTER, bd.Align.CENTER))
684
+ segs.append(cyl.moved(bd.Location(bd.Plane(origin=bd.Vector(*((a + b) / 2)), z_dir=bd.Vector(*(v / ln))))))
685
+ s = bd.Compound(children=segs)
686
+ s.label = f"conductor_{c['position']}"
687
+ solids.append(s)
688
+ for g, meshes in groups.items():
689
+ if g == "conductors":
690
+ continue
691
+ for m in meshes:
692
+ if "_housing" in m["name"]:
693
+ v = np.asarray(m["vertices"])
694
+ o = v[0]
695
+ ax = [v[4] - v[0], v[2] - v[0], v[1] - v[0]]
696
+ size = [np.linalg.norm(a) for a in ax]
697
+ loc = bd.Location(bd.Plane(origin=bd.Vector(*o), x_dir=bd.Vector(*unit(ax[0])), z_dir=bd.Vector(*unit(ax[2]))))
698
+ b = bd.Box(size[0], size[1], size[2], align=(bd.Align.MIN, bd.Align.MIN, bd.Align.MIN)).moved(loc)
699
+ b.label = m["name"]
700
+ solids.append(b)
701
+ elif "_joint_" in m["name"]:
702
+ c = np.asarray(m["vertices"]).mean(axis=0)
703
+ sph = bd.Sphere(max(0.9, od * 0.55)).moved(bd.Location(bd.Vector(*c)))
704
+ sph.label = m["name"]
705
+ solids.append(sph)
706
+ comp = bd.Compound(label=h["id"], children=solids)
707
+ bd.export_step(comp, str(path))
708
+ # the STEP processor stamps the export time; fix it so re-exports are byte-identical
709
+ path.write_text(re.sub(r"FILE_NAME\('([^']*)','[^']*'", lambda m: f"FILE_NAME('{m.group(1)}','{FIXED_TIME}'", path.read_text(), count=1))
710
+ return "ok"
711
+
712
+
713
+ def route_job(job, out: Path) -> dict:
714
+ """Route the job's harnesses in the system's order and write <id>.route.json for each
715
+ target (the job's `targets`, default all). A harness routed before a target is routed
716
+ again here, in memory, so a target's route does not depend on which harnesses were
717
+ generated when; harnesses after the last target are not routed."""
718
+ t0 = time.time()
719
+ om = obstacle_map(job)
720
+ t_map = time.time() - t0
721
+ order = routing_order(job["harnesses"])
722
+ targets = set(job.get("targets") or [h["id"] for h in order])
723
+ last = max((k for k, h in enumerate(order) if h["id"] in targets), default=-1)
724
+ out.mkdir(parents=True, exist_ok=True)
725
+ results, context = [], []
726
+ for h in order[: last + 1]:
727
+ try:
728
+ route, t_search = route_one(h, om)
729
+ entry = {"id": h["id"], "path": h["path"], "status": "ok" if not route["notes"] else "warning", "notes": route["notes"], "search_s": round(t_search, 2)}
730
+ if h["id"] in targets:
731
+ (out / f"{h['id']}.route.json").write_text(json.dumps(route, separators=(",", ":")) + "\n")
732
+ except Exception as e: # noqa: BLE001
733
+ entry = {"id": h["id"], "path": h["path"], "status": "failed", "notes": [f"{type(e).__name__}: {e}"]}
734
+ (results if h["id"] in targets else context).append(entry)
735
+ print(f" {entry['path']}: {entry['status']} {entry.get('notes', '')}{'' if h['id'] in targets else ' (routed before a target, not written)'}", file=sys.stderr, flush=True)
736
+ return {"tool": TOOL, "map": {"shape": om.shape, "res_mm": om.res, "seconds": round(t_map, 1)}, "harnesses": results, "context": context}
737
+
738
+
739
+ def main():
740
+ args = [a for a in sys.argv[1:] if not a.startswith("--")]
741
+ step_ok = "--no-step" not in sys.argv
742
+ if args and args[0] == "route":
743
+ print(json.dumps(route_job(json.loads(Path(args[1]).read_text()), Path(args[2]))))
744
+ elif args and args[0] == "export":
745
+ out = Path(args[1])
746
+ results = []
747
+ for f in args[2:]:
748
+ route = json.loads(Path(f).read_text())
749
+ try:
750
+ results.append(export_route(route, out, step_ok))
751
+ except Exception as e: # noqa: BLE001
752
+ results.append({"id": route["harness"]["id"], "path": route["harness"]["path"], "status": "failed", "notes": [f"{type(e).__name__}: {e}"]})
753
+ print(json.dumps({"tool": TOOL, "harnesses": results}))
754
+ else:
755
+ # route and export every harness of the job into one folder
756
+ job, out = json.loads(Path(args[0]).read_text()), Path(args[1])
757
+ routed = route_job(job, out)
758
+ results = []
759
+ for r in routed["harnesses"]:
760
+ f = out / f"{r['id']}.route.json"
761
+ if r["status"] == "failed":
762
+ results.append(r)
763
+ continue
764
+ try:
765
+ results.append({**export_route(json.loads(f.read_text()), out, step_ok), "search_s": r.get("search_s")})
766
+ except Exception as e: # noqa: BLE001
767
+ results.append({**r, "status": "failed", "notes": [*r["notes"], f"{type(e).__name__}: {e}"]})
768
+ f.unlink()
769
+ print(json.dumps({**routed, "harnesses": results}))
770
+
771
+
772
+ if __name__ == "__main__":
773
+ main()