@protoboardai/ext-enclosure-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 +388 -2
- package/evals/fit-grown-board/case.ts +71 -0
- package/lib/check.ts +379 -0
- package/lib/features.ts +424 -0
- package/lib/index.ts +8 -0
- package/lib/job.ts +388 -0
- package/lib/pyjson.ts +68 -0
- package/lib/standards.ts +302 -0
- package/ops/cad.ts +264 -0
- package/ops/package.ts +213 -0
- package/package.json +52 -4
- package/protoboard-extension.json +130 -0
- package/py/enclosure_cad.py +1731 -0
- package/py/fit.py +341 -0
- package/skills/enclosure-cad/SKILL.md +194 -0
package/py/fit.py
ADDED
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
"""Fit analysis: what goes inside an enclosure against the enclosure's walls.
|
|
2
|
+
|
|
3
|
+
Built on ext-harness-cad's obstacle map (py/route.py) and GLB reader
|
|
4
|
+
(py/glb.py), imported from that package rather than copied: the printed
|
|
5
|
+
parts are voxelised and filled solid, and the Euclidean distance transform
|
|
6
|
+
of free space gives the clearance field the harness router uses. Two more
|
|
7
|
+
fields come from the same occupancy grid:
|
|
8
|
+
|
|
9
|
+
depth distance from a solid cell to free space, so a point inside a
|
|
10
|
+
wall has a negative signed clearance (a collision) and its depth;
|
|
11
|
+
thickness the local wall thickness, twice the depth at the solid's medial
|
|
12
|
+
surface (local maxima of `depth`), minus one cell.
|
|
13
|
+
|
|
14
|
+
Every inside body (board, battery, display, ...) is sampled on its surface
|
|
15
|
+
and looked up in two ways:
|
|
16
|
+
|
|
17
|
+
exact against the primitives the generator built the inside from: the
|
|
18
|
+
cavity (a box or a cylinder) and the solids standing in it (insert
|
|
19
|
+
bosses, lid screw columns, gland bosses: finite cylinders). Good to
|
|
20
|
+
the sampling step (half a cell) on curved edges, exact on faces;
|
|
21
|
+
voxel in the signed field of the parts as built, which also sees
|
|
22
|
+
anything the primitives do not describe (a hand-edited script).
|
|
23
|
+
Good to about one cell (`resolution_mm`), so it reports a
|
|
24
|
+
collision only past one cell of penetration and a tight fit only
|
|
25
|
+
one cell under the clearance.
|
|
26
|
+
|
|
27
|
+
Contact the design intends (a board on its bosses) is excluded by zones the
|
|
28
|
+
job gives per body. Containment in the cavity primitive is exact (mesh
|
|
29
|
+
vertices against the box or cylinder), so a body that is bigger than its
|
|
30
|
+
cavity is caught whatever the voxel size.
|
|
31
|
+
|
|
32
|
+
Button plungers are inside bodies too, but they pass through the wall by
|
|
33
|
+
design: they are checked against the other contents in a voxel field of the
|
|
34
|
+
contents (analyse_plungers), and against the walls exactly by the generator.
|
|
35
|
+
"""
|
|
36
|
+
from __future__ import annotations
|
|
37
|
+
|
|
38
|
+
import math
|
|
39
|
+
import os
|
|
40
|
+
import sys
|
|
41
|
+
from pathlib import Path
|
|
42
|
+
|
|
43
|
+
import numpy as np
|
|
44
|
+
from scipy import ndimage
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def harness_py() -> Path:
|
|
48
|
+
"""ext-harness-cad's py/ folder: PROTOBOARD_HARNESS_CAD_PY, else the sibling package in this repository."""
|
|
49
|
+
env = os.environ.get("PROTOBOARD_HARNESS_CAD_PY")
|
|
50
|
+
if env:
|
|
51
|
+
return Path(env)
|
|
52
|
+
return Path(__file__).resolve().parents[2] / "ext-harness-cad" / "py"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
sys.path.insert(0, str(harness_py()))
|
|
56
|
+
from glb import read_glb, to_cad_mm # noqa: E402 (ext-harness-cad)
|
|
57
|
+
from route import ObstacleMap # noqa: E402 (ext-harness-cad)
|
|
58
|
+
|
|
59
|
+
MAX_CELLS = 6_000_000
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def load_mesh(path: str | Path, matrix=None, node: str | None = None, skip=None) -> tuple[np.ndarray, np.ndarray]:
|
|
63
|
+
"""Every triangle of a GLB (or of its nodes named `node`, or all but those in `skip`) in CAD millimetres (Z up), transformed by a row-major 4x4 when given."""
|
|
64
|
+
vs, fs, n = [], [], 0
|
|
65
|
+
skip = set(skip or [])
|
|
66
|
+
for name, v, f in read_glb(path):
|
|
67
|
+
if node is not None and name != node:
|
|
68
|
+
continue
|
|
69
|
+
if name in skip:
|
|
70
|
+
continue
|
|
71
|
+
w = to_cad_mm(v)
|
|
72
|
+
if matrix is not None:
|
|
73
|
+
m = np.asarray(matrix, dtype=float).reshape(4, 4)
|
|
74
|
+
w = w @ m[:3, :3].T + m[:3, 3]
|
|
75
|
+
vs.append(w)
|
|
76
|
+
fs.append(f + n)
|
|
77
|
+
n += len(w)
|
|
78
|
+
if not vs:
|
|
79
|
+
return np.zeros((0, 3)), np.zeros((0, 3), dtype=np.int64)
|
|
80
|
+
return np.concatenate(vs), np.concatenate(fs)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def sample_surface(v: np.ndarray, f: np.ndarray, step: float) -> np.ndarray:
|
|
84
|
+
"""Vertices plus barycentric samples no more than `step` apart on every triangle."""
|
|
85
|
+
if not len(f):
|
|
86
|
+
return v
|
|
87
|
+
tri = v[f]
|
|
88
|
+
edge = np.max(np.linalg.norm(tri - np.roll(tri, 1, axis=1), axis=2), axis=1)
|
|
89
|
+
steps = np.maximum(1, np.ceil(edge / step).astype(int))
|
|
90
|
+
out = [v]
|
|
91
|
+
for n in np.unique(steps):
|
|
92
|
+
sel = tri[steps == n]
|
|
93
|
+
a, b = np.meshgrid(np.arange(n + 1), np.arange(n + 1), indexing="ij")
|
|
94
|
+
m = (a + b) <= n
|
|
95
|
+
u = (a[m] / n)[:, None]
|
|
96
|
+
w = (b[m] / n)[:, None]
|
|
97
|
+
out.append((sel[:, None, 0, :] * (1 - u - w)[None] + sel[:, None, 1, :] * u[None] + sel[:, None, 2, :] * w[None]).reshape(-1, 3))
|
|
98
|
+
return np.concatenate(out)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def in_zones(pts: np.ndarray, zones: list[dict]) -> np.ndarray:
|
|
102
|
+
"""Points inside any contact zone: a vertical cylinder (centre x, y; radius; z range), or one along `axis` (centre x, y, z; z range along the axis from the centre)."""
|
|
103
|
+
hit = np.zeros(len(pts), dtype=bool)
|
|
104
|
+
for z in zones:
|
|
105
|
+
c = np.asarray(z["centre"], dtype=float)
|
|
106
|
+
if "axis" in z:
|
|
107
|
+
a = np.asarray(z["axis"], dtype=float)
|
|
108
|
+
a = a / np.linalg.norm(a)
|
|
109
|
+
t = (pts - c) @ a
|
|
110
|
+
r = np.linalg.norm(pts - c - t[:, None] * a, axis=1)
|
|
111
|
+
hit |= (r <= z["radius"]) & (t >= z["z"][0]) & (t <= z["z"][1])
|
|
112
|
+
continue
|
|
113
|
+
r = np.hypot(pts[:, 0] - c[0], pts[:, 1] - c[1])
|
|
114
|
+
hit |= (r <= z["radius"]) & (pts[:, 2] >= z["z"][0]) & (pts[:, 2] <= z["z"][1])
|
|
115
|
+
return hit
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def cylinder_distance(pts: np.ndarray, s: dict) -> np.ndarray:
|
|
119
|
+
"""Signed distance from points to a finite solid cylinder (origin at one end, axis, radius, length): negative inside."""
|
|
120
|
+
a = np.asarray(s["axis"], dtype=float)
|
|
121
|
+
a = a / np.linalg.norm(a)
|
|
122
|
+
d = pts - np.asarray(s["origin"], dtype=float)
|
|
123
|
+
t = d @ a
|
|
124
|
+
dr = np.linalg.norm(d - t[:, None] * a, axis=1) - s["radius"]
|
|
125
|
+
dz = np.maximum(-t, t - s["length"])
|
|
126
|
+
outside = np.hypot(np.maximum(dr, 0.0), np.maximum(dz, 0.0))
|
|
127
|
+
return np.where((dr > 0) | (dz > 0), outside, np.maximum(dr, dz))
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def exact_clearance(pts: np.ndarray, cavity: dict) -> tuple[np.ndarray, np.ndarray]:
|
|
131
|
+
"""Signed clearance (mm) from points to the cavity's walls and the solids standing in it, and which part each nearest wall belongs to.
|
|
132
|
+
|
|
133
|
+
Inside the cavity and clear of every solid it is the distance to the
|
|
134
|
+
nearest of them; a point in a solid or outside the cavity is negative.
|
|
135
|
+
"""
|
|
136
|
+
names = []
|
|
137
|
+
cols = []
|
|
138
|
+
lo, hi = np.asarray(cavity["min"], dtype=float), np.asarray(cavity["max"], dtype=float)
|
|
139
|
+
side_top = cavity.get("side_top", hi[2])
|
|
140
|
+
if cavity["shape"] == "round":
|
|
141
|
+
c = cavity["centre"]
|
|
142
|
+
cols.append(cavity["radius"] - np.hypot(pts[:, 0] - c[0], pts[:, 1] - c[1]))
|
|
143
|
+
names.append("side")
|
|
144
|
+
else:
|
|
145
|
+
for k in (0, 1):
|
|
146
|
+
cols += [pts[:, k] - lo[k], hi[k] - pts[:, k]]
|
|
147
|
+
names += ["side", "side"]
|
|
148
|
+
cols += [pts[:, 2] - lo[2], hi[2] - pts[:, 2]]
|
|
149
|
+
names += ["floor", "ceiling"]
|
|
150
|
+
solids = cavity.get("solids", [])
|
|
151
|
+
for s in solids:
|
|
152
|
+
# only points that can be nearer to it than to everything else's floor of 1 mm past the clearance need the distance
|
|
153
|
+
d = np.full(len(pts), np.inf)
|
|
154
|
+
o, a = np.asarray(s["origin"], dtype=float), np.asarray(s["axis"], dtype=float)
|
|
155
|
+
ends = np.stack([o, o + a / np.linalg.norm(a) * s["length"]])
|
|
156
|
+
reach = s["radius"] + 2.0
|
|
157
|
+
near = np.all((pts >= ends.min(axis=0) - reach) & (pts <= ends.max(axis=0) + reach), axis=1)
|
|
158
|
+
d[near] = cylinder_distance(pts[near], s)
|
|
159
|
+
cols.append(d)
|
|
160
|
+
names.append(s["name"])
|
|
161
|
+
m = np.stack(cols, axis=1)
|
|
162
|
+
k = np.argmin(m, axis=1)
|
|
163
|
+
split = cavity.get("split_z", hi[2])
|
|
164
|
+
part = np.empty(len(pts), dtype=object)
|
|
165
|
+
for i, name in enumerate(names):
|
|
166
|
+
sel = k == i
|
|
167
|
+
if not sel.any():
|
|
168
|
+
continue
|
|
169
|
+
z = pts[sel, 2]
|
|
170
|
+
if name == "floor":
|
|
171
|
+
part[sel] = "base"
|
|
172
|
+
elif name == "ceiling":
|
|
173
|
+
part[sel] = "lid"
|
|
174
|
+
elif name == "side":
|
|
175
|
+
part[sel] = np.where(z < side_top, "base", "lid")
|
|
176
|
+
else:
|
|
177
|
+
s = solids[i - (len(names) - len(solids))]
|
|
178
|
+
part[sel] = s.get("part") or np.where(z < split, "base", "lid")
|
|
179
|
+
labels = np.array([f"{p} ({names[j] if names[j] not in ('side', 'floor', 'ceiling') else 'cavity ' + names[j]})" for p, j in zip(part, k)], dtype=object)
|
|
180
|
+
return m[np.arange(len(pts)), k], labels
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def outside_cavity(v: np.ndarray, cavity: dict) -> tuple[float, list[float] | None]:
|
|
184
|
+
"""How far (mm) the furthest vertex lies outside the cavity primitive, and where."""
|
|
185
|
+
if not len(v):
|
|
186
|
+
return 0.0, None
|
|
187
|
+
if cavity["shape"] == "round":
|
|
188
|
+
c = cavity["centre"]
|
|
189
|
+
out_r = np.hypot(v[:, 0] - c[0], v[:, 1] - c[1]) - cavity["radius"]
|
|
190
|
+
out_xy = out_r
|
|
191
|
+
else:
|
|
192
|
+
(x0, y0, _), (x1, y1, _) = cavity["min"], cavity["max"]
|
|
193
|
+
out_xy = np.maximum.reduce([x0 - v[:, 0], v[:, 0] - x1, y0 - v[:, 1], v[:, 1] - y1])
|
|
194
|
+
out_z = np.maximum(cavity["min"][2] - v[:, 2], v[:, 2] - cavity["max"][2])
|
|
195
|
+
out = np.maximum(out_xy, out_z)
|
|
196
|
+
k = int(np.argmax(out))
|
|
197
|
+
return float(out[k]), [round(float(x), 2) for x in v[k]]
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def analyse(parts: dict[str, tuple[np.ndarray, np.ndarray]], bodies: list[dict], cavity: dict, resolution: float, clearance: float) -> dict:
|
|
201
|
+
"""parts: wall parts {name: (V, F)}; bodies: [{key, label, V, F, zones}]. Returns the fit report."""
|
|
202
|
+
allv = np.concatenate([p[0] for p in parts.values()] + [b["V"] for b in bodies if len(b["V"])])
|
|
203
|
+
lo = allv.min(axis=0) - 2.0
|
|
204
|
+
hi = allv.max(axis=0) + 2.0
|
|
205
|
+
res = float(resolution)
|
|
206
|
+
cells = np.prod(np.ceil((hi - lo) / res) + 1)
|
|
207
|
+
if cells > MAX_CELLS:
|
|
208
|
+
res = float(np.ceil(((np.prod(hi - lo) / MAX_CELLS) ** (1 / 3)) * 100) / 100)
|
|
209
|
+
om = ObstacleMap(np.floor(lo), np.ceil(hi), res)
|
|
210
|
+
for name, (v, f) in sorted(parts.items()):
|
|
211
|
+
om.add_solid(v, f, name)
|
|
212
|
+
om.finalize()
|
|
213
|
+
depth = ndimage.distance_transform_edt(om.occ) * res
|
|
214
|
+
|
|
215
|
+
def depth_at(pts):
|
|
216
|
+
g = (np.asarray(pts, dtype=float) - om.lo) / res
|
|
217
|
+
return ndimage.map_coordinates(depth, g.T, order=1, mode="nearest")
|
|
218
|
+
|
|
219
|
+
out_bodies = []
|
|
220
|
+
for b in bodies:
|
|
221
|
+
entry = {"key": b["key"], "label": b.get("label", b["key"])}
|
|
222
|
+
if not len(b["V"]):
|
|
223
|
+
entry.update({"status": "no_geometry"})
|
|
224
|
+
out_bodies.append(entry)
|
|
225
|
+
continue
|
|
226
|
+
pts = sample_surface(b["V"], b["F"], res * 0.5)
|
|
227
|
+
keep = ~in_zones(pts, b.get("zones", []))
|
|
228
|
+
pts = pts[keep]
|
|
229
|
+
out_mm, out_at = outside_cavity(b["V"], cavity)
|
|
230
|
+
if not len(pts):
|
|
231
|
+
entry.update({"status": "clear", "samples": 0, "outside_cavity_mm": round(max(out_mm, 0.0), 3)})
|
|
232
|
+
out_bodies.append(entry)
|
|
233
|
+
continue
|
|
234
|
+
signed = om.clearance(pts) - depth_at(pts)
|
|
235
|
+
kv = int(np.argmin(signed))
|
|
236
|
+
v_worst = float(signed[kv])
|
|
237
|
+
exact, near = exact_clearance(pts, cavity)
|
|
238
|
+
ke = int(np.argmin(exact))
|
|
239
|
+
e_worst = float(exact[ke])
|
|
240
|
+
# exact: no tolerance; voxel: one cell either way
|
|
241
|
+
e_status = "collision" if e_worst < -1e-3 else ("tight" if e_worst < clearance - 1e-3 else "clear")
|
|
242
|
+
v_status = "collision" if v_worst < -res else ("tight" if v_worst < clearance - res else "clear")
|
|
243
|
+
rank = {"clear": 0, "tight": 1, "collision": 2}
|
|
244
|
+
voxel = rank[v_status] > rank[e_status]
|
|
245
|
+
status = "collision" if out_mm > 1e-3 else max(e_status, v_status, key=rank.get)
|
|
246
|
+
worst, at, by = (v_worst, pts[kv], om.owner_at(pts[kv])) if voxel else (e_worst, pts[ke], str(near[ke]))
|
|
247
|
+
entry.update(
|
|
248
|
+
{
|
|
249
|
+
"min_clearance_mm": round(worst, 3),
|
|
250
|
+
"at": [round(float(x), 2) for x in at],
|
|
251
|
+
"near": by,
|
|
252
|
+
"basis": "voxel" if voxel else "exact",
|
|
253
|
+
"exact_clearance_mm": round(e_worst, 3),
|
|
254
|
+
"voxel_clearance_mm": round(v_worst, 2),
|
|
255
|
+
"outside_cavity_mm": round(max(out_mm, 0.0), 3),
|
|
256
|
+
**({"outside_at": out_at} if out_mm > 1e-3 else {}),
|
|
257
|
+
"samples": int(len(pts)),
|
|
258
|
+
"status": status,
|
|
259
|
+
}
|
|
260
|
+
)
|
|
261
|
+
out_bodies.append(entry)
|
|
262
|
+
|
|
263
|
+
# local wall thickness at the medial surface of each part
|
|
264
|
+
walls = {}
|
|
265
|
+
peak = (ndimage.maximum_filter(depth, size=3) <= depth + 1e-9) & om.occ & (depth >= res)
|
|
266
|
+
border = np.zeros_like(peak)
|
|
267
|
+
border[2:-2, 2:-2, 2:-2] = True
|
|
268
|
+
peak &= border
|
|
269
|
+
for k, name in enumerate(om.names):
|
|
270
|
+
sel = peak & (om.owner == k)
|
|
271
|
+
if not sel.any():
|
|
272
|
+
continue
|
|
273
|
+
d = depth[sel]
|
|
274
|
+
i = int(np.argmin(d))
|
|
275
|
+
idx = np.argwhere(sel)[i]
|
|
276
|
+
walls[name] = {"min_mm": round(float(2 * d[i] - res), 2), "at": [round(float(x), 2) for x in om.centre(idx)]}
|
|
277
|
+
return {"resolution_mm": res, "grid": list(om.shape), "bodies": out_bodies, "walls": walls}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def local_map(bodies: list[dict], lo: np.ndarray, hi: np.ndarray, res: float) -> ObstacleMap:
|
|
281
|
+
"""An obstacle map of the bodies over a small box: each body is filled solid on its own grid (on the same lattice) and cropped in, so a body the box cuts through is still solid."""
|
|
282
|
+
om = ObstacleMap(lo, hi, res)
|
|
283
|
+
for b in bodies:
|
|
284
|
+
vmin, vmax = b["V"].min(axis=0), b["V"].max(axis=0)
|
|
285
|
+
if np.any(vmax < lo) or np.any(vmin > hi):
|
|
286
|
+
continue
|
|
287
|
+
blo = om.lo + (np.floor((vmin - om.lo) / res) - 2) * res
|
|
288
|
+
bm = ObstacleMap(blo, vmax + 2 * res, res)
|
|
289
|
+
bm.add_solid(b["V"], b["F"], b["key"])
|
|
290
|
+
off = np.round((bm.lo - om.lo) / res).astype(int)
|
|
291
|
+
a0 = np.maximum(off, 0)
|
|
292
|
+
a1 = np.minimum(off + np.array(bm.shape), np.array(om.shape))
|
|
293
|
+
if np.any(a1 <= a0):
|
|
294
|
+
continue
|
|
295
|
+
local = bm.occ[tuple(slice(x - k, y - k) for x, y, k in zip(a0, a1, off))]
|
|
296
|
+
sl = tuple(slice(x, y) for x, y in zip(a0, a1))
|
|
297
|
+
om.occ[sl] |= local
|
|
298
|
+
if b["key"] not in om.names:
|
|
299
|
+
om.names.append(b["key"])
|
|
300
|
+
om.owner[sl][local] = om.names.index(b["key"])
|
|
301
|
+
om.finalize()
|
|
302
|
+
return om
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def analyse_plungers(plungers: list[dict], bodies: list[dict], resolution: float, clearance: float) -> list[dict]:
|
|
306
|
+
"""Each plunger against what is inside (not the walls: it passes through one by design), in a voxel field of the contents around it.
|
|
307
|
+
|
|
308
|
+
plungers: [{key, feature, V, F, zones}], the zone being its tip, which
|
|
309
|
+
meets the switch it presses by design (the gap there is checked exactly).
|
|
310
|
+
"""
|
|
311
|
+
inside = [b for b in bodies if len(b["V"])]
|
|
312
|
+
res = float(resolution)
|
|
313
|
+
out = []
|
|
314
|
+
for p in plungers:
|
|
315
|
+
entry = {"key": p["key"], "feature": p["feature"]}
|
|
316
|
+
if not inside or not len(p["V"]):
|
|
317
|
+
out.append({**entry, "status": "clear", "near": None})
|
|
318
|
+
continue
|
|
319
|
+
margin = clearance + 1.0 + 2 * res
|
|
320
|
+
om = local_map(inside, np.floor(p["V"].min(axis=0) - margin), np.ceil(p["V"].max(axis=0) + margin), res)
|
|
321
|
+
pts = sample_surface(p["V"], p["F"], res * 0.5)
|
|
322
|
+
pts = pts[~in_zones(pts, p.get("zones", []))]
|
|
323
|
+
if not len(pts) or not om.occ.any():
|
|
324
|
+
out.append({**entry, "status": "clear", "near": None})
|
|
325
|
+
continue
|
|
326
|
+
depth = ndimage.distance_transform_edt(om.occ) * res
|
|
327
|
+
signed = om.clearance(pts) - ndimage.map_coordinates(depth, ((pts - om.lo) / res).T, order=1, mode="nearest")
|
|
328
|
+
k = int(np.argmin(signed))
|
|
329
|
+
worst = float(signed[k])
|
|
330
|
+
out.append(
|
|
331
|
+
{
|
|
332
|
+
**entry,
|
|
333
|
+
"min_clearance_mm": round(worst, 2),
|
|
334
|
+
"at": [round(float(x), 2) for x in pts[k]],
|
|
335
|
+
"near": om.owner_at(pts[k]),
|
|
336
|
+
"basis": "voxel",
|
|
337
|
+
"resolution_mm": res,
|
|
338
|
+
"status": "collision" if worst < -res else ("tight" if worst < clearance - res else "clear"),
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
return out
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: enclosure-cad
|
|
3
|
+
description: Design a custom enclosure (case, cap, back cover) as a UHD module of features, generate it with the @protoboardai/ext-enclosure-cad cad provider (build123d; protoboard run cad:generate / cad:export / cad:extract --module <id>), export print files, and fix what its check finds (collisions, clearance, thin walls, seal squeeze, misaligned openings and plungers, drift). Use when a project needs a printed or machined enclosure around a board and other parts, or when ENCLOSURE_* diagnostics fire.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Enclosure CAD
|
|
7
|
+
|
|
8
|
+
An enclosure is a UHD module whose interfaces are the features it must
|
|
9
|
+
provide. The provider reads them, reads what the assembly puts inside, and
|
|
10
|
+
generates the enclosure. Full reference: the package README
|
|
11
|
+
(`node_modules/@protoboardai/ext-enclosure-cad/README.md`).
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
enclosure module (features) + assembly (what is inside) + cad.config.ts
|
|
15
|
+
│ protoboard run cad:generate --module <id>
|
|
16
|
+
▼
|
|
17
|
+
build123d/<id>.job.json, <id>.py ─► build ─► <id>.report.json (fit, walls, seals) (native design)
|
|
18
|
+
│ protoboard run cad:export --module <id>
|
|
19
|
+
▼
|
|
20
|
+
out/body.step, body.glb, body.manifest.json, section.svg, <part>.stl / .3mf (standard outputs)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Purpose
|
|
24
|
+
|
|
25
|
+
Generate a case around a board and the other parts inside it, with every
|
|
26
|
+
opening, button, window, seal and fastening derived from the model, and a
|
|
27
|
+
check that says whether it fits. Use it when a project needs a printed or
|
|
28
|
+
machined enclosure, when anything inside it moves or changes size, and when
|
|
29
|
+
`ENCLOSURE_*` diagnostics fire. Plates and frames from bolt patterns are
|
|
30
|
+
**frame-cad**. In **build-a-project** it is stage 6, after the boards
|
|
31
|
+
(stage 5): the enclosure is fitted around `out/board.glb` and aligned to the
|
|
32
|
+
frames in `out/board.manifest.json`.
|
|
33
|
+
|
|
34
|
+
## Inputs
|
|
35
|
+
|
|
36
|
+
| Input | Where | From |
|
|
37
|
+
| --- | --- | --- |
|
|
38
|
+
| Everything inside, placed | mates to the enclosure (a board on insert bosses), or display placements in `.protoboard/display.json` (a cell held by tape) | **system-compose** |
|
|
39
|
+
| A body GLB for each placed part | a board's `out/board.glb`; a part's body artifact | **circuit-board**, the parts |
|
|
40
|
+
| Frames of what the enclosure serves (connectors, switches, LEDs, sensors), normal pointing out of the part | the board's `out/board.manifest.json`, or UHD frames | **circuit-board**, **uhd-part-author** |
|
|
41
|
+
| The size limit | a `design_envelope` trait from the brief | **system-compose** |
|
|
42
|
+
| Standard parts (O-rings, inserts, screws, spring bars, plugs) | the cited tables in `lib/standards.ts` | this provider |
|
|
43
|
+
| The `cad` runtime (build123d + scipy) | `npx protoboard doctor` | `npx protoboard tools install cad` |
|
|
44
|
+
|
|
45
|
+
## Steps
|
|
46
|
+
|
|
47
|
+
### 1. Before you start
|
|
48
|
+
|
|
49
|
+
- Everything that goes inside is placed in the assembly: mated to the
|
|
50
|
+
enclosure (a board on insert bosses: a structure bolt pattern on the
|
|
51
|
+
enclosure, a component bolt pattern on the board) or given a display
|
|
52
|
+
placement in `.protoboard/display.json` (a cell held by tape).
|
|
53
|
+
- Each placed part has a body GLB: a board's `out/board.glb` from its
|
|
54
|
+
circuit provider, a part's body artifact. A part without one is reported
|
|
55
|
+
and not fitted.
|
|
56
|
+
- The connectors, switches, LEDs and sensors the enclosure serves have
|
|
57
|
+
frames, with the normal pointing out of the part: in the board's geometry
|
|
58
|
+
manifest (`out/board.manifest.json`), or as UHD frames.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx protoboard check --format json # no UHD_UNPLACED for parts that go inside; boards exported (no PB_OUTPUT_NOT_EXPORTED)
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### 2. Describe the enclosure
|
|
65
|
+
|
|
66
|
+
In `src/enclosures/<module>/<module>.uhd.ts`, build its interfaces with the
|
|
67
|
+
builders from `@protoboardai/ext-enclosure-cad/lib/index.ts`: `Opening`,
|
|
68
|
+
`Button`, `Window`, `LightPipe`, `InsertBosses`, `SealGland`, `Thread`,
|
|
69
|
+
`StrapLugs`, `Joint`, `TwistLid`. Give a served feature `serves: { child, interfaceId }`
|
|
70
|
+
(a sibling in the system). Take standard parts from the cited tables
|
|
71
|
+
(`as568("AS568-138")`, `RUTHEX_RX_M2X4`, `M2X6_PAN`, `ESSLINGER_82_2225`,
|
|
72
|
+
`USB_C_PLUG` as an opening's `plug`; on a round wall add `seat: "flat"` so the overmold bears on a
|
|
73
|
+
flat seat instead of the curve) or
|
|
74
|
+
state your own values with a `source` or an `assumption`. Never write a
|
|
75
|
+
tool's name, document id or file layout into the module. Add a
|
|
76
|
+
`design_envelope` trait when the brief limits the size.
|
|
77
|
+
|
|
78
|
+
An outside `Window`'s cover is generated as a part of its own
|
|
79
|
+
(`out/cover_<window>.step|glb|dxf`, in the cover's frame): when it is bought or
|
|
80
|
+
cut separately (a lens), give it a module whose artifacts are
|
|
81
|
+
`coverArtifacts(out, windowId)` and a seat interface (custom peer, capability
|
|
82
|
+
`window_cover`, frame at its outer face, normal into the body), and link it to
|
|
83
|
+
the window, so the BOM and the assembly guide carry it. A `Joint` that screws a
|
|
84
|
+
lid on links to itself (`case.back_joint` ↔ `case.back_joint`) with
|
|
85
|
+
`harness: <screw harness>`; that harness's `fastenerStack` places the inserts
|
|
86
|
+
and screws at the columns the build report gives (`features.<joint>.columns`).
|
|
87
|
+
|
|
88
|
+
### 3. Configure and generate
|
|
89
|
+
|
|
90
|
+
`src/enclosures/<module>/cad.config.ts`:
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
export default defineCapabilityConfig({
|
|
94
|
+
provider: "@protoboardai/ext-enclosure-cad",
|
|
95
|
+
process: "print",
|
|
96
|
+
constraints: { wall_mm: 1.7, clearance_mm: 0.3, material: "PETG", fasteners: "screws", joint: { style: "rabbet", clearance_mm: 0.1 } },
|
|
97
|
+
});
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
A rabbet joint needs `wall_mm` ≥ 2 × the minimum wall + the joint
|
|
101
|
+
clearance (print: 2 × 0.8 + 0.1 = 1.7 mm). The full list of constraints is
|
|
102
|
+
in the README, "Configuration".
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
npx protoboard tools install cad # once per checkout: the shared cad runtime (or PROTOBOARD_PYTHON=<python with build123d and scipy>)
|
|
106
|
+
npx protoboard capabilities # the enclosure is bound to @protoboardai/ext-enclosure-cad ✓
|
|
107
|
+
npx protoboard run cad:generate --module <instance or module id>
|
|
108
|
+
npx protoboard run cad:export --module <instance or module id>
|
|
109
|
+
npx protoboard check
|
|
110
|
+
npx protoboard run cad:extract --module <instance or module id> --format json # frames and artifacts to reference
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Read the generate summary: the parts, every body's fit status (clear,
|
|
114
|
+
tight, collision) and any generator problem.
|
|
115
|
+
|
|
116
|
+
Commit the native design (`build123d/<id>.job.json`, `<id>.py`,
|
|
117
|
+
`<id>.report.json`, `<id>.source.json`) and `out/`. `build123d/build/` is a
|
|
118
|
+
cache the repository ignores; `check` works from the report and `out/`.
|
|
119
|
+
|
|
120
|
+
### 4. Bind what extract returns
|
|
121
|
+
|
|
122
|
+
`cad:extract` returns the generated frame of every feature, keyed by
|
|
123
|
+
interface id, and the artifacts the module should reference (`cad_source`,
|
|
124
|
+
`cad_step`, `cad_glb`, `cad_manifest`, `<part>_stl`, `<part>_3mf`). Writing
|
|
125
|
+
them into the module source is a manual re-binding step for now (no
|
|
126
|
+
`enclosure-cad-rebind` operation yet): copy the artifacts and frames into
|
|
127
|
+
the module, then `npx protoboard check` must show no
|
|
128
|
+
`ENCLOSURE_FRAME_MISMATCH` or `UHD_GEOMETRY_BINDING`.
|
|
129
|
+
|
|
130
|
+
### 5. Look at it
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
npx protoboard dev --open # the assembled enclosure with its contents, live; Ctrl+C stops it
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
`out/section.svg` is a section through the case for a quick look at walls and
|
|
137
|
+
clearances.
|
|
138
|
+
|
|
139
|
+
## Decision points
|
|
140
|
+
|
|
141
|
+
| Decision | Rule |
|
|
142
|
+
| --- | --- |
|
|
143
|
+
| `process` | `print` (STL and 3MF per printed part) or `machine` (STEP). `cut` is refused: cut panels are **frame-cad**. |
|
|
144
|
+
| Wall and joint | `wall_mm` ≥ 2 × minimum wall + joint clearance for a rabbet; a joint O-ring makes the joint flat. |
|
|
145
|
+
| Screws or a twist lid | When the size limit leaves no room for screw columns outside the lid seal (they bulge past the shell), a round enclosure closes with a `TwistLid` and a `SealGland` radial `at: "joint"` (`fasteners: "none"`). Bayonet when a feature in the lid must line up with what is under it (light pipes, a window): the slot ends fix the angle, and a key lug (`keyLugWidthMm`) makes it go on one way only (equal lugs let it go on n ways, which the build refuses unless `orientation: "any"`); thread otherwise. Printed: give the seal bore a `boreAllowanceMm` (about 0.1 mm radial for FDM PETG), since Parker's bore clearance (under 0.1 mm diametral) is tighter than a print holds, and measure the first printed bore. The collar (O-ring groove plus lock, about 8-10 mm) must sit above every cut through the side wall, since the lid turns: raise the cavity top if it does not. |
|
|
146
|
+
| Standard part or own values | A cited table entry when one fits; otherwise your values with a `source` or an `assumption`. |
|
|
147
|
+
| An intended contact | Only bosses declare contact zones; other intended contact needs the cavity clearance or the body excluded (`exclude`), said in the module. |
|
|
148
|
+
| Hand-edit the native design | Allowed (`export` rebuilds from it), but `check` then reports drift until `generate` writes it again from the model; prefer changing the module or the config. |
|
|
149
|
+
|
|
150
|
+
## Outputs
|
|
151
|
+
|
|
152
|
+
| Output | Where |
|
|
153
|
+
| --- | --- |
|
|
154
|
+
| The enclosure module | `src/enclosures/<module>/<module>.uhd.ts` |
|
|
155
|
+
| Native design (committed) | `src/enclosures/<module>/build123d/<id>.job.json`, `<id>.py`, `<id>.report.json`, `<id>.source.json` |
|
|
156
|
+
| Build cache (ignored) | `src/enclosures/<module>/build123d/build/` |
|
|
157
|
+
| Standard outputs | `src/enclosures/<module>/out/`: `body.step`, `body.glb`, `body.manifest.json`, `section.svg`, `<part>.stl`, `<part>.3mf` |
|
|
158
|
+
|
|
159
|
+
## Verification
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
npx protoboard check --format json # the enclosure's cad:check ran: no ENCLOSURE_* error or warning; standard-outputs ran
|
|
163
|
+
npx protoboard run cad:generate --module <id> # repeatable: a second run changes no committed file
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
## Failure handling
|
|
167
|
+
|
|
168
|
+
| Finding | Usual fix |
|
|
169
|
+
| --- | --- |
|
|
170
|
+
| `ENCLOSURE_NOT_GENERATED` | `run cad:generate`, then `cad:export` |
|
|
171
|
+
| `ENCLOSURE_COLLISION`, `ENCLOSURE_NOT_CONTAINED` | Something grew or moved since generation: regenerate. If it still collides, a boss, column or gland boss is in the way: move the part, add a board cutout, or set `cavity_mm`. A plunger in collision: the switch is too close to something else, or the native design was edited |
|
|
172
|
+
| `ENCLOSURE_CLEARANCE` | Raise `clearance_mm`, or accept it if the contact is intended (say so in the module) |
|
|
173
|
+
| `ENCLOSURE_THIN_WALL` | Thicker `wall_mm`/`lid_mm`, a smaller cut, or move the feature away from an edge or off the split plane (a ledge left on the outer face by a cut just under the split); for the rabbet's tongue and skirt, a thicker wall or a smaller joint clearance |
|
|
174
|
+
| `ENCLOSURE_MISALIGNED`, `ENCLOSURE_RECESS` | The served part moved: regenerate; if the recess is real, move the part to the wall or size the opening for the plug's overmold |
|
|
175
|
+
| `ENCLOSURE_PLUNGER` | Change `gapMm` or `protrudeMm`, or move the switch. A flange that no longer holds was trimmed to clear the board or the lid: move the switch further from the board's surface or the cavity's top, or use a smaller bore |
|
|
176
|
+
| `ENCLOSURE_PLUG_FIT` | Size the opening for the plug, or move it so the overmold sits on flat wall (away from the case's edges and the screw columns) |
|
|
177
|
+
| `ENCLOSURE_SEAL_SQUEEZE`, `ENCLOSURE_SEAL_FIT` | Choose the AS568 size whose I.D. matches the groove; do not edit gland depths away from Parker's table (the check compares with the table, not the job's copy) |
|
|
178
|
+
| `ENCLOSURE_STALE` | Review what changed (listed), then regenerate |
|
|
179
|
+
| `ENCLOSURE_NATIVE_EDITED` | The job or script was edited: `cad:export` rebuilds from it; `cad:generate` replaces it from the model |
|
|
180
|
+
| `ENCLOSURE_GENERATOR_STALE` | The provider's generator changed since the design was built: `cad:generate`, `cad:export`, review the diff and commit it with the run records |
|
|
181
|
+
| `ENCLOSURE_FIT_UNCHECKED` | The contents changed and Python is missing: `npx protoboard tools install cad`, then `check` |
|
|
182
|
+
| `ENCLOSURE_INPUT`, `ENCLOSURE_FEATURE_INVALID` | The model does not say enough: add the frame or placement the message names; build features with the builders |
|
|
183
|
+
| `ENCLOSURE_FEATURE_MISSING`, `ENCLOSURE_FRAME_MISMATCH` | Regenerate; re-bind the frames `cad:extract` returns |
|
|
184
|
+
| `ENCLOSURE_GENERATOR` | Read the message: no room for a plunger flange, a feature that cut nothing, a frame off its surface |
|
|
185
|
+
| `ENCLOSURE_ENVELOPE` | Thinner walls, a tighter cavity, or revisit the envelope with the brief's owner |
|
|
186
|
+
| `PB_OUTPUT_MISSING` (STL/3MF) | `process: print` without print files: re-export |
|
|
187
|
+
|
|
188
|
+
Every other code: **system-compose** [diagnostic codes](../system-compose/references/diagnostic-codes.md).
|
|
189
|
+
|
|
190
|
+
## Hard rules
|
|
191
|
+
|
|
192
|
+
1. Never write a tool's name, document id or file layout into the UHD module.
|
|
193
|
+
2. Never edit `out/`; change the module or the config and regenerate.
|
|
194
|
+
3. Do not print, upload or order anything: the provider stops at files.
|