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

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/py/route.py ADDED
@@ -0,0 +1,534 @@
1
+ """Obstacle map and route search for wire harnesses.
2
+
3
+ The assembled model is voxelised (1 mm) from its GLB bodies, each body
4
+ filled solid. A clearance field (distance to the nearest solid) drives an
5
+ A* search for the bundle's centreline between the two ends' exit points:
6
+ a cell is allowed when the bundle fits (clearance >= bundle radius +
7
+ margin), and cheaper when the bundle rests on something below it (a cable
8
+ lies on the frame; it does not float) or at least touches a surface.
9
+
10
+ The voxel path is then shortened (line of sight in the clearance field),
11
+ its corners filleted to the target bend radius where there is room, and
12
+ lengthened to the modelled cable length with a smooth bow where the
13
+ clearance allows (the slack of a cable that is longer than its route).
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import heapq
18
+ import math
19
+ import os
20
+ import sys
21
+
22
+ DEBUG = bool(os.environ.get("HARNESS_CAD_DEBUG"))
23
+
24
+ import numpy as np
25
+ from scipy import ndimage
26
+
27
+
28
+ class ObstacleMap:
29
+ def __init__(self, lo, hi, res: float = 1.0):
30
+ self.res = res
31
+ self.lo = np.asarray(lo, dtype=float)
32
+ self.shape = tuple(int(math.ceil((h - l) / res)) + 1 for l, h in zip(lo, hi))
33
+ self.occ = np.zeros(self.shape, dtype=bool)
34
+ self.owner = np.full(self.shape, -1, dtype=np.int16)
35
+ self.names: list[str] = []
36
+ self.dist: np.ndarray | None = None
37
+
38
+ # ---- building --------------------------------------------------------------------------
39
+ def index(self, p) -> tuple[int, int, int]:
40
+ i = np.round((np.asarray(p, dtype=float) - self.lo) / self.res).astype(int)
41
+ return tuple(np.clip(i, 0, np.array(self.shape) - 1))
42
+
43
+ def centre(self, idx) -> np.ndarray:
44
+ return self.lo + np.asarray(idx, dtype=float) * self.res
45
+
46
+ def add_solid(self, vertices: np.ndarray, faces: np.ndarray, name: str = "") -> None:
47
+ """Surface-sample the triangles into a local box, fill it solid, OR it in."""
48
+ if not len(faces):
49
+ return
50
+ vmin = vertices.min(axis=0) - 2 * self.res
51
+ vmax = vertices.max(axis=0) + 2 * self.res
52
+ i0 = np.maximum(np.floor((vmin - self.lo) / self.res).astype(int), 0)
53
+ i1 = np.minimum(np.ceil((vmax - self.lo) / self.res).astype(int) + 1, np.array(self.shape))
54
+ if np.any(i1 <= i0):
55
+ return
56
+ local = np.zeros(tuple(i1 - i0), dtype=bool)
57
+ tri = vertices[faces]
58
+ edge = np.max(np.linalg.norm(tri - np.roll(tri, 1, axis=1), axis=2), axis=1)
59
+ steps = np.maximum(1, np.ceil(edge / (0.45 * self.res)).astype(int))
60
+ for n in np.unique(steps):
61
+ sel = tri[steps == n]
62
+ a, b = np.meshgrid(np.arange(n + 1), np.arange(n + 1), indexing="ij")
63
+ m = (a + b) <= n
64
+ u = (a[m] / n)[:, None]
65
+ v = (b[m] / n)[:, None]
66
+ w = 1 - u - v
67
+ pts = (sel[:, None, 0, :] * w[None] + sel[:, None, 1, :] * u[None] + sel[:, None, 2, :] * v[None]).reshape(-1, 3)
68
+ idx = np.round((pts - self.lo) / self.res).astype(int) - i0
69
+ ok = np.all((idx >= 0) & (idx < local.shape), axis=1)
70
+ idx = idx[ok]
71
+ local[idx[:, 0], idx[:, 1], idx[:, 2]] = True
72
+ local = ndimage.binary_fill_holes(local)
73
+ sl = tuple(slice(a, b) for a, b in zip(i0, i1))
74
+ self.occ[sl] |= local
75
+ if name not in self.names:
76
+ self.names.append(name)
77
+ self.owner[sl][local] = self.names.index(name)
78
+
79
+ def owner_at(self, p) -> str:
80
+ """The body occupying (or nearest below) a point, for reports."""
81
+ i = self.index(p)
82
+ k = int(self.owner[i])
83
+ if k < 0:
84
+ sl = tuple(slice(max(0, a - 2), a + 3) for a in i)
85
+ vals = self.owner[sl][self.owner[sl] >= 0]
86
+ k = int(vals[0]) if len(vals) else -1
87
+ return self.names[k] if k >= 0 else "?"
88
+
89
+ def stamp_tube(self, points: np.ndarray, radius: float, name: str = "") -> None:
90
+ """Mark a routed conductor as solid (so later routes keep off it)."""
91
+ r = int(math.ceil(radius / self.res))
92
+ offs = np.array([(x, y, z) for x in range(-r, r + 1) for y in range(-r, r + 1) for z in range(-r, r + 1) if (x * x + y * y + z * z) * self.res**2 <= radius**2 + 1e-9])
93
+ idx = np.round((points - self.lo) / self.res).astype(int)
94
+ allidx = (idx[:, None, :] + offs[None]).reshape(-1, 3)
95
+ ok = np.all((allidx >= 0) & (allidx < np.array(self.shape)), axis=1)
96
+ allidx = allidx[ok]
97
+ self.occ[allidx[:, 0], allidx[:, 1], allidx[:, 2]] = True
98
+ if name not in self.names:
99
+ self.names.append(name)
100
+ self.owner[allidx[:, 0], allidx[:, 1], allidx[:, 2]] = self.names.index(name)
101
+ self.dist = None
102
+
103
+ def finalize(self) -> None:
104
+ self.dist = ndimage.distance_transform_edt(~self.occ) * self.res
105
+ # "supported": solid within a few mm straight below
106
+ k = 4
107
+ sup = np.zeros_like(self.occ)
108
+ for d in range(1, k + 1):
109
+ sup[:, :, d:] |= self.occ[:, :, :-d]
110
+ self.support = sup
111
+
112
+ # ---- queries ---------------------------------------------------------------------------
113
+ def clearance(self, pts: np.ndarray) -> np.ndarray:
114
+ """Trilinear clearance at points (mm)."""
115
+ assert self.dist is not None
116
+ f = (np.asarray(pts, dtype=float) - self.lo) / self.res
117
+ return ndimage.map_coordinates(self.dist, f.T, order=1, mode="nearest")
118
+
119
+ def gradient(self, p) -> np.ndarray:
120
+ e = self.res
121
+ g = np.array([(self.clearance([p + d])[0] - self.clearance([p - d])[0]) / (2 * e) for d in np.eye(3) * e])
122
+ n = np.linalg.norm(g)
123
+ return g / n if n > 1e-9 else np.zeros(3)
124
+
125
+
126
+ _N26 = [(dx, dy, dz) for dx in (-1, 0, 1) for dy in (-1, 0, 1) for dz in (-1, 0, 1) if (dx, dy, dz) != (0, 0, 0)]
127
+
128
+
129
+ def astar(om: ObstacleMap, start, goal, radius: float, free_zones: list[tuple[np.ndarray, float]], max_expand: int = 4_000_000):
130
+ """Cheapest 26-connected voxel path from start to goal for a bundle of `radius`.
131
+
132
+ Cells need clearance >= radius, except inside `free_zones` (spheres around
133
+ the terminations, where the cable meets its part by design). Cost per mm:
134
+ 1.0 resting on something below, 1.5 touching a surface, 2.6 in free air.
135
+ """
136
+ assert om.dist is not None
137
+ s = om.index(start)
138
+ g = om.index(goal)
139
+ shape = np.array(om.shape)
140
+ dist = om.dist
141
+ sup = om.support
142
+ zones = [(om.index(c), r / om.res) for c, r in free_zones]
143
+
144
+ def allowed(c):
145
+ if dist[c] >= radius:
146
+ return True
147
+ return any((c[0] - z[0]) ** 2 + (c[1] - z[1]) ** 2 + (c[2] - z[2]) ** 2 <= rr * rr for z, rr in zones)
148
+
149
+ def weight(c):
150
+ d = dist[c]
151
+ if sup[c] and d <= radius + 2.5:
152
+ return 1.0
153
+ if d <= radius + 2.0:
154
+ return 1.5
155
+ return 2.6
156
+
157
+ steps = [(d, math.sqrt(d[0] ** 2 + d[1] ** 2 + d[2] ** 2) * om.res) for d in _N26]
158
+ h = lambda c: math.dist(c, g) * om.res
159
+ openq = [(h(s), 0.0, s)]
160
+ best = {s: 0.0}
161
+ parent = {s: None}
162
+ n = 0
163
+ while openq:
164
+ _, cost, c = heapq.heappop(openq)
165
+ if c == g:
166
+ path = []
167
+ while c is not None:
168
+ path.append(c)
169
+ c = parent[c]
170
+ return [om.centre(p) for p in reversed(path)]
171
+ if cost > best.get(c, math.inf) + 1e-9:
172
+ continue
173
+ n += 1
174
+ if n > max_expand:
175
+ break
176
+ for d, l in steps:
177
+ nb = (c[0] + d[0], c[1] + d[1], c[2] + d[2])
178
+ if nb[0] < 0 or nb[1] < 0 or nb[2] < 0 or nb[0] >= shape[0] or nb[1] >= shape[1] or nb[2] >= shape[2]:
179
+ continue
180
+ if not allowed(nb):
181
+ continue
182
+ nc = cost + l * weight(nb)
183
+ if nc < best.get(nb, math.inf):
184
+ best[nb] = nc
185
+ parent[nb] = c
186
+ heapq.heappush(openq, (nc + h(nb), nc, nb))
187
+ return None
188
+
189
+
190
+ # ---------------------------------------------------------------------------------------------
191
+ # Path shaping
192
+ # ---------------------------------------------------------------------------------------------
193
+
194
+
195
+ def seg_ok(om: ObstacleMap, a, b, radius: float, zones) -> bool:
196
+ n = max(2, int(np.linalg.norm(b - a) / 0.5) + 1)
197
+ pts = a + (b - a) * np.linspace(0, 1, n)[:, None]
198
+ c = om.clearance(pts)
199
+ bad = c < radius
200
+ if not bad.any():
201
+ return True
202
+ for p in pts[bad]:
203
+ if not any(np.linalg.norm(p - zc) <= zr for zc, zr in zones):
204
+ return False
205
+ return True
206
+
207
+
208
+ def pull_string(om: ObstacleMap, pts: list[np.ndarray], radius: float, zones) -> list[np.ndarray]:
209
+ out = [pts[0]]
210
+ i = 0
211
+ while i < len(pts) - 1:
212
+ j = len(pts) - 1
213
+ while j > i + 1 and not seg_ok(om, pts[i], pts[j], radius, zones):
214
+ j -= 1
215
+ out.append(pts[j])
216
+ i = j
217
+ return out
218
+
219
+
220
+ def fillet(poly: list[np.ndarray], radius: float, fixed_ends: int = 1, step: float = 0.5) -> tuple[np.ndarray, list[float]]:
221
+ """Round every interior corner with a circular arc of up to `radius` (limited by the adjacent segments)."""
222
+ pts = [np.asarray(p, dtype=float) for p in poly]
223
+ out = [pts[0]]
224
+ radii = []
225
+ for k in range(1, len(pts) - 1):
226
+ a, v, b = out[-1], pts[k], pts[k + 1]
227
+ u = a - v
228
+ w = b - v
229
+ lu, lw = np.linalg.norm(u), np.linalg.norm(w)
230
+ if lu < 1e-6 or lw < 1e-6:
231
+ continue
232
+ u /= lu
233
+ w /= lw
234
+ cosang = float(np.clip(np.dot(u, w), -1, 1))
235
+ theta = math.acos(cosang) # interior angle
236
+ if theta > math.pi - 1e-3:
237
+ out.append(v)
238
+ continue
239
+ if theta < 0.05: # the path doubles back on itself: drop the spike
240
+ continue
241
+ t_max = min(lu * (1.0 if k == 1 else 0.5), lw * (1.0 if k == len(pts) - 2 else 0.5))
242
+ t = radius / math.tan(theta / 2)
243
+ if t > t_max:
244
+ t = t_max
245
+ r = t * math.tan(theta / 2)
246
+ radii.append(r)
247
+ p1 = v + u * t
248
+ p2 = v + w * t
249
+ bis = u + w
250
+ bis /= np.linalg.norm(bis)
251
+ centre = v + bis * (r / math.sin(theta / 2))
252
+ a1 = p1 - centre
253
+ a2 = p2 - centre
254
+ ang = math.acos(float(np.clip(np.dot(a1, a2) / (np.linalg.norm(a1) * np.linalg.norm(a2)), -1, 1)))
255
+ n = max(2, int(r * ang / step) + 1)
256
+ for s in np.linspace(0, 1, n):
257
+ # slerp between a1 and a2
258
+ if ang < 1e-6:
259
+ q = a1
260
+ else:
261
+ q = (math.sin((1 - s) * ang) * a1 + math.sin(s * ang) * a2) / math.sin(ang)
262
+ out.append(centre + q)
263
+ out.append(pts[-1])
264
+ return resample(np.array(out), step), radii
265
+
266
+
267
+ def resample(pts: np.ndarray, step: float = 0.5) -> np.ndarray:
268
+ d = np.linalg.norm(np.diff(pts, axis=0), axis=1)
269
+ keep = np.concatenate([[True], d > 1e-9])
270
+ pts = pts[keep]
271
+ s = np.concatenate([[0], np.cumsum(np.linalg.norm(np.diff(pts, axis=0), axis=1))])
272
+ if s[-1] < 1e-9:
273
+ return pts
274
+ n = max(2, int(s[-1] / step) + 1)
275
+ t = np.linspace(0, s[-1], n)
276
+ return np.stack([np.interp(t, s, pts[:, k]) for k in range(3)], axis=1)
277
+
278
+
279
+ def length(pts: np.ndarray) -> float:
280
+ return float(np.linalg.norm(np.diff(pts, axis=0), axis=1).sum())
281
+
282
+
283
+ def arclen(pts: np.ndarray) -> np.ndarray:
284
+ return np.concatenate([[0], np.cumsum(np.linalg.norm(np.diff(pts, axis=0), axis=1))])
285
+
286
+
287
+ def min_radius(pts: np.ndarray, spacing: float = 2.0, skip: float = 1.0) -> float:
288
+ """Smallest three-point circle radius along a polyline (points `spacing` apart)."""
289
+ p = resample(pts, spacing)
290
+ s = arclen(p)
291
+ best = math.inf
292
+ for k in range(1, len(p) - 1):
293
+ if s[k] < skip or s[-1] - s[k] < skip:
294
+ continue
295
+ a, b, c = p[k - 1], p[k], p[k + 1]
296
+ ab, bc, ca = np.linalg.norm(b - a), np.linalg.norm(c - b), np.linalg.norm(a - c)
297
+ area2 = np.linalg.norm(np.cross(b - a, c - a))
298
+ if area2 < 1e-9:
299
+ continue
300
+ best = min(best, ab * bc * ca / (2 * area2))
301
+ return best
302
+
303
+
304
+ def smooth(pts: np.ndarray, om: ObstacleMap, radius: float, fixed: int, iters: int = 30, zones=()) -> np.ndarray:
305
+ """Laplacian smoothing that never moves a point into a collision; `fixed` points at each end stay."""
306
+ p = pts.copy()
307
+ for _ in range(iters):
308
+ q = p.copy()
309
+ q[fixed:-fixed] = 0.5 * p[fixed:-fixed] + 0.25 * (p[fixed - 1 : -fixed - 1] + p[fixed + 1 : len(p) - fixed + 1])
310
+ c = om.clearance(q[fixed:-fixed])
311
+ ok = c >= radius
312
+ if zones:
313
+ for i, pt in enumerate(q[fixed:-fixed]):
314
+ if not ok[i] and any(np.linalg.norm(pt - zc) <= zr for zc, zr in zones):
315
+ ok[i] = True
316
+ p[fixed:-fixed][ok] = q[fixed:-fixed][ok]
317
+ return p
318
+
319
+
320
+ def bow(pts: np.ndarray, s0: float, s1: float, direction: np.ndarray, amp: float) -> np.ndarray:
321
+ """Displace the section [s0, s1] (arc length) by amp * sin^2 along `direction` (made normal to the local tangent)."""
322
+ s = arclen(pts)
323
+ out = pts.copy()
324
+ t = np.gradient(pts, axis=0)
325
+ t /= np.maximum(np.linalg.norm(t, axis=1, keepdims=True), 1e-12)
326
+ for k in range(len(pts)):
327
+ if s0 < s[k] < s1:
328
+ w = math.sin(math.pi * (s[k] - s0) / (s1 - s0)) ** 2
329
+ d = direction - t[k] * np.dot(direction, t[k])
330
+ nd = np.linalg.norm(d)
331
+ if nd > 1e-9:
332
+ out[k] = pts[k] + d / nd * amp * w
333
+ return out
334
+
335
+
336
+ def bezier_loop(pts: np.ndarray, s0: float, s1: float, direction: np.ndarray, amp: float, spread: float = 0.6) -> np.ndarray:
337
+ """Replace [s0, s1] with a cubic Bezier that leaves and rejoins tangentially and bulges `amp` along `direction`."""
338
+ s = arclen(pts)
339
+ i0 = int(np.searchsorted(s, s0))
340
+ i1 = int(np.searchsorted(s, s1))
341
+ i0 = max(1, min(i0, len(pts) - 3))
342
+ i1 = max(i0 + 2, min(i1, len(pts) - 2))
343
+ p0, p1 = pts[i0], pts[i1]
344
+ t0 = pts[i0] - pts[i0 - 1]
345
+ t1 = pts[i1 + 1] - pts[i1]
346
+ t0 /= max(np.linalg.norm(t0), 1e-9)
347
+ t1 /= max(np.linalg.norm(t1), 1e-9)
348
+ d = direction - t0 * np.dot(direction, t0)
349
+ d /= max(np.linalg.norm(d), 1e-9)
350
+ c0 = p0 + t0 * amp * spread + d * amp
351
+ c1 = p1 - t1 * amp * spread + d * amp
352
+ u = np.linspace(0, 1, max(8, int((np.linalg.norm(p1 - p0) + 3 * amp) / 0.5)))[:, None]
353
+ curve = (1 - u) ** 3 * p0 + 3 * (1 - u) ** 2 * u * c0 + 3 * (1 - u) * u**2 * c1 + u**3 * p1
354
+ return np.vstack([pts[:i0], curve, pts[i1 + 1 :]])
355
+
356
+
357
+ def apex_loop(poly: list[np.ndarray], om: ObstacleMap, radius: float, zones, target: float, bend_target: float, bend_min: float, fixed: int, skip: float = 4.0):
358
+ """Lay slack as a service loop: one apex point pulled out from the route, joined by straight legs, corners filleted.
359
+
360
+ Tries an apex after every interior vertex (and at segment midpoints), in
361
+ fourteen directions (twelve around the local route direction, up, down);
362
+ sizes each by bisection to the target length after filleting; keeps the
363
+ candidates whose legs are clear and whose bends keep the minimum radius;
364
+ returns the one that stays lowest and closest to surfaces (a loop that
365
+ lies on something, drooping rather than standing up), or None.
366
+ """
367
+ pts = [np.asarray(p, dtype=float) for p in poly]
368
+ inserts = []
369
+ for i in range(1, len(pts) - 2):
370
+ inserts.append((i, pts[i]))
371
+ inserts.append((i, 0.5 * (pts[i] + pts[i + 1])))
372
+ if not inserts:
373
+ return None
374
+ best = None
375
+ for i, base in inserts:
376
+ t = pts[i + 1] - pts[i]
377
+ t /= max(np.linalg.norm(t), 1e-9)
378
+ e1 = np.cross(t, [0, 0, 1.0])
379
+ if np.linalg.norm(e1) < 1e-6:
380
+ e1 = np.cross(t, [1.0, 0, 0])
381
+ e1 /= np.linalg.norm(e1)
382
+ e2 = np.cross(t, e1)
383
+ dirs = [math.cos(a) * e1 + math.sin(a) * e2 for a in np.linspace(0, 2 * math.pi, 12, endpoint=False)] + [np.array([0, 0, 1.0]), np.array([0, 0, -1.0])]
384
+ for d in dirs:
385
+ for kind in ("U", "V"):
386
+ sep = 2.2 * bend_target
387
+
388
+ def build(dist, kind=kind, d=d, base=base, i=i, t=t):
389
+ if kind == "V":
390
+ apices = [base + d * dist]
391
+ else:
392
+ apices = [base + d * dist - t * sep / 2, base + d * dist + t * sep / 2]
393
+ q = pts[: i + 1] + ([base] if base is not pts[i] else []) + apices + pts[i + 1 :]
394
+ c, _ = fillet(q, bend_target)
395
+ return c, apices
396
+
397
+ lo, hi = 0.0, 3.0
398
+ while length(build(hi)[0]) < target and hi < 120:
399
+ hi *= 1.5
400
+ if length(build(hi)[0]) < target:
401
+ continue
402
+ for _ in range(30):
403
+ m = 0.5 * (lo + hi)
404
+ if length(build(m)[0]) < target:
405
+ lo = m
406
+ else:
407
+ hi = m
408
+ cand, apices = build(hi)
409
+ chain = [base] + apices + [pts[i + 1]]
410
+ if not all(seg_ok(om, a, b, radius, zones) for a, b in zip(chain[:-1], chain[1:])):
411
+ continue
412
+ cand = smooth(cand, om, radius, fixed=fixed, iters=20, zones=zones)
413
+ clear = om.clearance(cand) - radius
414
+ inside = np.array([any(np.linalg.norm(p - zc) <= zr for zc, zr in zones) for p in cand])
415
+ worst = float(clear[~inside].min()) if (~inside).any() else 0.0
416
+ bend = min_radius(cand, 1.5, skip=skip)
417
+ if DEBUG:
418
+ print(f"apex {kind} i={i} d={np.round(d, 2)} leg={hi:.1f} worst={worst:.2f} bend={bend:.2f}", file=sys.stderr)
419
+ if worst < 0 or bend < bend_min:
420
+ continue
421
+ touching = float(np.mean(clear[~inside] < 2.5)) if (~inside).any() else 0.0
422
+ rise = max(0.0, max(a[2] for a in apices) - base[2])
423
+ score = touching - 0.02 * rise + 0.3 * min(bend / bend_target, 1.0) - 0.004 * hi
424
+ if best is None or score > best[0]:
425
+ best = (score, cand, {"method": "service loop", "shape": kind, "apex_mm": [[round(x, 1) for x in a] for a in apices], "leg_mm": round(hi, 1), "bend_mm": round(bend, 2)})
426
+ return (best[1], best[2]) if best else None
427
+
428
+
429
+ def fit_length(pts: np.ndarray, target: float, om: ObstacleMap, radius: float, keep: float, zones, bend_min: float = 0.0, bend_target: float = 0.0) -> tuple[np.ndarray, dict]:
430
+ """Lengthen the centreline to `target` by laying the slack in clear space.
431
+
432
+ Candidates: a sin^2 bow or a tangent Bezier loop over windows of the free
433
+ middle of the route, bulging in twelve directions around the route and
434
+ away from the nearest surface. Each is sized (bisection) to the exact
435
+ length, then scored: it must stay clear and keep the minimum bend radius;
436
+ among those, the one that stays closest to surfaces (lying on the frame,
437
+ not standing in the air) and bends least wins. If none qualifies, the
438
+ least bad one is returned and reported.
439
+ """
440
+ base = length(pts)
441
+ info = {"slack_mm": round(target - base, 2), "method": "none"}
442
+ if target <= base + 0.3:
443
+ return pts, info
444
+ s = arclen(pts)
445
+ total = s[-1]
446
+ lo_s, hi_s = keep, total - keep
447
+ if hi_s - lo_s < 4:
448
+ lo_s, hi_s = 0.15 * total, 0.85 * total
449
+ windows = []
450
+ for frac in (1.0, 0.8, 0.6, 0.4):
451
+ w = (hi_s - lo_s) * frac
452
+ for centre in (np.linspace(lo_s + w / 2, hi_s - w / 2, 3) if frac < 1 else [(lo_s + hi_s) / 2]):
453
+ windows.append((centre - w / 2, centre + w / 2))
454
+ best_ok, best_bad = None, None
455
+ for s0, s1 in windows:
456
+ a_i, b_i = int(np.searchsorted(s, s0).clip(0, len(pts) - 1)), int(np.searchsorted(s, s1).clip(0, len(pts) - 1))
457
+ chord = pts[b_i] - pts[a_i]
458
+ chord /= max(np.linalg.norm(chord), 1e-9)
459
+ e1 = np.cross(chord, [0, 0, 1.0])
460
+ if np.linalg.norm(e1) < 1e-6:
461
+ e1 = np.cross(chord, [1.0, 0, 0])
462
+ e1 /= np.linalg.norm(e1)
463
+ e2 = np.cross(chord, e1)
464
+ mid = pts[(a_i + b_i) // 2]
465
+ dirs = [math.cos(a) * e1 + math.sin(a) * e2 for a in np.linspace(0, 2 * math.pi, 12, endpoint=False)] + [om.gradient(mid)]
466
+ for d in dirs:
467
+ if np.linalg.norm(d) < 1e-6:
468
+ continue
469
+ for shape in ("bow", "loop"):
470
+ f = (lambda a: bow(pts, s0, s1, d, a)) if shape == "bow" else (lambda a: bezier_loop(pts, s0, s1, d, a))
471
+ lo, hi = 0.0, 4.0
472
+ while length(f(hi)) < target and hi < 400:
473
+ hi *= 1.6
474
+ if length(f(hi)) < target:
475
+ continue
476
+ for _ in range(36):
477
+ m = 0.5 * (lo + hi)
478
+ if length(f(m)) < target:
479
+ lo = m
480
+ else:
481
+ hi = m
482
+ cand = resample(f(hi), 0.5)
483
+ cs = arclen(cand)
484
+ k2 = min(keep, 0.25 * cs[-1])
485
+ inner = cand[(cs > k2) & (cs < cs[-1] - k2)]
486
+ if not len(inner):
487
+ continue
488
+ clear = om.clearance(inner) - radius
489
+ worst = float(clear.min())
490
+ bend = min_radius(cand, 1.5, skip=keep)
491
+ touching = float(np.mean(clear < 2.5))
492
+ ok = worst >= 0 and bend >= bend_min
493
+ score = touching + min(bend / max(bend_target, 1e-6), 1.0) - 0.002 * hi
494
+ rec = (score, cand, shape, hi, s0, s1, worst, bend)
495
+ if ok and (best_ok is None or score > best_ok[0]):
496
+ best_ok = rec
497
+ if not ok:
498
+ badness = min(worst, 0) + min(bend - bend_min, 0) * 0.5
499
+ if best_bad is None or badness > best_bad[0]:
500
+ best_bad = (badness, cand, shape, hi, s0, s1, worst, bend)
501
+ pick = best_ok or best_bad
502
+ if pick is None:
503
+ info.update(method="failed")
504
+ return pts, info
505
+ _, cand, shape, amp, s0, s1, worst, bend = pick
506
+ info.update(method=shape if best_ok else f"{shape} (not clear)", amplitude_mm=round(amp, 2), window_mm=[round(s0, 1), round(s1, 1)], worst_clearance_mm=round(worst, 2), bend_mm=round(bend, 2))
507
+ return cand, info
508
+
509
+
510
+ def rmf(pts: np.ndarray, n0: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
511
+ """Rotation-minimising frames (double reflection): tangents, normals, binormals."""
512
+ t = np.gradient(pts, axis=0)
513
+ t /= np.maximum(np.linalg.norm(t, axis=1, keepdims=True), 1e-12)
514
+ r = np.zeros_like(pts)
515
+ first = n0 - t[0] * np.dot(n0, t[0])
516
+ if np.linalg.norm(first) < 1e-6:
517
+ first = np.cross(t[0], [0, 0, 1.0])
518
+ if np.linalg.norm(first) < 1e-6:
519
+ first = np.cross(t[0], [1.0, 0, 0])
520
+ r[0] = first / np.linalg.norm(first)
521
+ for i in range(len(pts) - 1):
522
+ v1 = pts[i + 1] - pts[i]
523
+ c1 = np.dot(v1, v1)
524
+ if c1 < 1e-12:
525
+ r[i + 1] = r[i]
526
+ continue
527
+ rl = r[i] - (2 / c1) * np.dot(v1, r[i]) * v1
528
+ tl = t[i] - (2 / c1) * np.dot(v1, t[i]) * v1
529
+ v2 = t[i + 1] - tl
530
+ c2 = np.dot(v2, v2)
531
+ r[i + 1] = rl - (2 / c2) * np.dot(v2, rl) * v2 if c2 > 1e-12 else rl
532
+ r[i + 1] /= np.linalg.norm(r[i + 1])
533
+ b = np.cross(t, r)
534
+ return t, r, b