@protoboardai/ext-enclosure-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/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.