@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.
- package/LICENSE +202 -0
- package/NOTICE +13 -0
- package/README.md +10 -2
- package/checks/routes.ts +31 -0
- package/evals/reroute-after-vtx-move/case.json +47 -0
- package/lib/artifacts.ts +121 -0
- package/lib/connectors.ts +58 -0
- package/lib/job.ts +273 -0
- package/lib/route.ts +216 -0
- package/ops/cad.ts +45 -0
- package/ops/harness-cad.ts +91 -0
- package/ops/package.ts +108 -0
- package/package.json +54 -4
- package/protoboard-extension.json +106 -0
- package/py/glb.py +171 -0
- package/py/harness_cad.py +773 -0
- package/py/route.py +534 -0
- package/skills/harness-cad/SKILL.md +170 -0
|
@@ -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()
|