@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/LICENSE +202 -0
- package/NOTICE +13 -0
- 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 +43 -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
- package/README.md +0 -3
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
|