@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.
@@ -0,0 +1,71 @@
1
+ /**
2
+ * The synthetic device from the provider's tests (test/fixture.ts): a board
3
+ * on four insert bosses and a pouch cell in a two-part printed case. The
4
+ * case is generated and exported for the 30 × 24 mm board; then the circuit
5
+ * provider's next export grows the board to 30 × 26 mm. The case no longer
6
+ * holds it: `protoboard check` reports the fit failure. The board is the
7
+ * given; the agent has to make the enclosure fit it again.
8
+ */
9
+ import { writeFileSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { defineEvalCase, runSteps } from "@protoboardai/core";
12
+ import { BOARD, boardBoxes, boardManifest, glbBoxes, writeFixture } from "../../test/fixture.ts";
13
+
14
+ const OUT = "src/boards/mainboard/out";
15
+ const GROWN = { w: BOARD.w, h: BOARD.h + 2 };
16
+
17
+ export default defineEvalCase({
18
+ title: "Make the enclosure fit a board that grew 2 mm",
19
+ skills: ["enclosure-cad"],
20
+ tags: ["cad", "fit"],
21
+ requires: ["python-build123d"],
22
+ fixture: {
23
+ recipe: async (dir, ctx) => {
24
+ writeFixture(dir);
25
+ await runSteps(
26
+ [
27
+ { protoboard: ["run", "cad:generate", "--module", "fixture-case"], expect: { status: "succeeded" } },
28
+ { protoboard: ["run", "cad:export", "--module", "fixture-case"], expect: { status: "succeeded" } },
29
+ { remove: "operations" },
30
+ ],
31
+ ctx,
32
+ );
33
+ // the board's next revision, as its circuit provider would export it
34
+ writeFileSync(join(dir, OUT, "board.glb"), glbBoxes(boardBoxes(GROWN.w, GROWN.h)));
35
+ const m = boardManifest();
36
+ m.features.outline.area_mm2 = GROWN.w * GROWN.h;
37
+ writeFileSync(join(dir, OUT, "board.manifest.json"), JSON.stringify(m, null, 2));
38
+ },
39
+ },
40
+ instruction:
41
+ "The mainboard's latest export (src/boards/mainboard/out) is 2 mm longer in Y than before, and `npx protoboard check` now reports that the case (src/enclosures/case, module fixture-case) does not hold it. The board is final: change the enclosure, not the board, so that check reports no errors and nothing stale for the case, and its standard outputs are fresh. Say what was wrong and what you changed.",
42
+ tools: { read: true, edit: true, commands: ["npx protoboard *"] },
43
+ budget: { turns: 25, seconds: 1200, usd: 3 },
44
+ graders: [
45
+ { id: "fit-fixed", kind: "fault", code: "ENCLOSURE_NOT_CONTAINED", seeded: ["ENCLOSURE_NOT_CONTAINED", "ENCLOSURE_STALE"] },
46
+ { id: "case-clean", kind: "check", maxErrors: 0, absent: ["ENCLOSURE_COLLISION", "ENCLOSURE_STALE", "ENCLOSURE_NOT_GENERATED"], ran: ["ext:@protoboardai/ext-enclosure-cad/cad:check"] },
47
+ { id: "outputs-valid", kind: "outputs", module: "fixture-case" },
48
+ { id: "base-printable", kind: "format", file: "src/enclosures/case/out/base.stl", format: "stl" },
49
+ { id: "lid-printable", kind: "format", file: "src/enclosures/case/out/lid.3mf", format: "3mf" },
50
+ { id: "body-regenerated", kind: "digest", file: "src/enclosures/case/out/body.step", changed: true },
51
+ { id: "board-unchanged", kind: "digest", file: `${OUT}/board.glb`, changed: false },
52
+ { id: "manifest-unchanged", kind: "digest", file: `${OUT}/board.manifest.json`, changed: false },
53
+ {
54
+ id: "everything-clear",
55
+ kind: "facts",
56
+ file: "src/enclosures/case/build123d/fixture-case.report.json",
57
+ facts: [
58
+ { path: "fit.bodies[key=board].status", equals: "clear" },
59
+ { path: "fit.bodies[key=battery].status", equals: "clear" },
60
+ { path: "problems", length: 0 },
61
+ ],
62
+ },
63
+ ],
64
+ solution: [
65
+ { protoboard: ["check"], expect: { exit: 1 } },
66
+ { protoboard: ["run", "cad:generate", "--module", "fixture-case"], expect: { status: "succeeded" } },
67
+ { protoboard: ["run", "cad:export", "--module", "fixture-case"], expect: { status: "succeeded" } },
68
+ { protoboard: ["check"] },
69
+ { say: "The case was generated for the 24 mm board, so the 26 mm board reached outside the cavity and into the base. The cavity follows its contents, so I regenerated the case (cad:generate) and exported it (cad:export); check is clean and the fit report shows the board and cell clear." },
70
+ ],
71
+ });
package/lib/check.ts ADDED
@@ -0,0 +1,379 @@
1
+ /**
2
+ * cad:check for enclosures: the generated design against the model and
3
+ * against what goes inside it. Pure: it reads the native design
4
+ * (build123d/<id>.job.json and <id>.report.json) and the project, and may
5
+ * run the fit analysis in Python (which writes nothing).
6
+ *
7
+ * Drift
8
+ * ENCLOSURE_NOT_GENERATED no generated design yet (info)
9
+ * ENCLOSURE_FEATURE_INVALID a feature interface the provider cannot read
10
+ * ENCLOSURE_INPUT the model does not say enough to design it (a served part has no frame, …)
11
+ * ENCLOSURE_STALE what goes inside, the features or the config changed since generate
12
+ * ENCLOSURE_NATIVE_EDITED the native design was edited by hand (info: export builds from it)
13
+ * ENCLOSURE_GENERATOR_STALE the design was built by another version of the generator (py/enclosure_cad.py or py/fit.py changed)
14
+ * ENCLOSURE_FEATURE_MISSING an interface with no generated feature, or a generated feature with no interface
15
+ * ENCLOSURE_FRAME_MISMATCH a declared frame differs from the generated one (re-bind after review)
16
+ * Fit (Python, ext-harness-cad's voxel clearance field; exact cavity containment)
17
+ * ENCLOSURE_COLLISION a body inside intersects a wall, boss or column; a plunger intersects a wall or a body inside
18
+ * ENCLOSURE_NOT_CONTAINED a body reaches outside the cavity
19
+ * ENCLOSURE_CLEARANCE a body (or a plunger) is closer to a wall (or a body) than the design clearance
20
+ * ENCLOSURE_FIT_UNCHECKED the fit could not be analysed (no Python)
21
+ * Features
22
+ * ENCLOSURE_THIN_WALL wall below the minimum: exact per feature, or from the voxel field
23
+ * ENCLOSURE_MISALIGNED an opening, window, light pipe or plunger off the axis of what it serves
24
+ * ENCLOSURE_RECESS what an opening serves sits deeper than it may
25
+ * ENCLOSURE_PLUNGER a plunger presses its switch at rest, cannot reach it, or its flange (trimmed to clear what is near it) no longer holds it in
26
+ * ENCLOSURE_PLUG_FIT the plug a socket opening takes does not pass, or its overmold has no flat seat
27
+ * ENCLOSURE_SEAL_SQUEEZE O-ring squeeze outside the range of the gland table for its cross-section
28
+ * ENCLOSURE_SEAL_FIT O-ring stretched past 5 %, or the groove not sized to it
29
+ * ENCLOSURE_ENVELOPE bigger than the module's design envelope
30
+ * ENCLOSURE_GENERATOR a problem the generator reported
31
+ */
32
+ import { createHash } from "node:crypto";
33
+ import { existsSync, readFileSync } from "node:fs";
34
+ import { join } from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import type { GeometryFrame, ModuleDef, Vec3 } from "@deltarobotics/uhd";
37
+ import type { Project, ProjectDiagnostic } from "@protoboardai/core";
38
+ import { readFeatures } from "./features.ts";
39
+ import { type BuiltJob, type Job } from "./job.ts";
40
+ import { pyJsonDumps } from "./pyjson.ts";
41
+ import { FACE_GROOVE_FIT, MAX_INSTALLED_STRETCH, glandRow } from "./standards.ts";
42
+
43
+ type Diagnostic = Omit<ProjectDiagnostic, "validator">;
44
+
45
+ export interface FitBody {
46
+ key: string;
47
+ label: string;
48
+ min_clearance_mm?: number;
49
+ at?: number[];
50
+ near?: string;
51
+ /** Which analysis found the worst point: exact (the cavity and the solids in it) or voxel (the parts as built, ±1 cell). */
52
+ basis?: "exact" | "voxel";
53
+ outside_cavity_mm?: number;
54
+ outside_at?: number[];
55
+ status: "clear" | "tight" | "collision" | "no_geometry";
56
+ }
57
+
58
+ export interface FitPlunger {
59
+ key: string;
60
+ feature: string;
61
+ min_clearance_mm?: number;
62
+ at?: number[];
63
+ near?: string | null;
64
+ /** Exact intersection with each wall part (mm³). */
65
+ wall_overlap_mm3?: Record<string, number>;
66
+ resolution_mm?: number;
67
+ status: "clear" | "tight" | "collision";
68
+ }
69
+
70
+ export interface Fit {
71
+ resolution_mm: number;
72
+ bodies: FitBody[];
73
+ plungers?: FitPlunger[];
74
+ walls: Record<string, { min_mm: number; at: number[] }>;
75
+ }
76
+
77
+ export interface Report {
78
+ schema: string;
79
+ module: string;
80
+ inputsDigest: string;
81
+ sourceDigest: string;
82
+ toolVersion: string;
83
+ cavity: { shape: string; min: Vec3; max: Vec3; centre?: number[]; radius?: number };
84
+ outer: { min: Vec3; max: Vec3 };
85
+ overall: { min: Vec3; max: Vec3 };
86
+ levels: Record<string, number>;
87
+ parts: Record<string, { volume_mm3: number; printed: boolean }>;
88
+ features: Record<string, Record<string, unknown> & { kind: string; frame?: GeometryFrame; parts: string[] }>;
89
+ walls: { feature: string; what: string; mm: number; min_mm: number }[];
90
+ /** Window covers (outside windows): each its own STEP, GLB and DXF outline, in its own frame. */
91
+ covers?: Record<string, { part: string; shape: string; size_mm: number[]; thickness_mm: number; material?: string; volume_mm3: number; frame: GeometryFrame }>;
92
+ problems: string[];
93
+ fit: Fit;
94
+ }
95
+
96
+ /** Where a module's native design lives. */
97
+ export function nativePaths(native: string, id: string) {
98
+ return { script: `${native}/${id}.py`, job: `${native}/${id}.job.json`, build: `${native}/build`, report: `${native}/${id}.report.json`, source: `${native}/${id}.source.json` };
99
+ }
100
+
101
+ export function readJson<T>(file: string): T | undefined {
102
+ if (!existsSync(file)) return undefined;
103
+ try {
104
+ return JSON.parse(readFileSync(file, "utf8")) as T;
105
+ } catch {
106
+ return undefined;
107
+ }
108
+ }
109
+
110
+ const sub = (a: number[], b: number[]) => a.map((v, i) => v - b[i]);
111
+ const dot = (a: number[], b: number[]) => a.reduce((s, v, i) => s + v * b[i], 0);
112
+ const len = (a: number[]) => Math.hypot(...a);
113
+ const unit = (a: number[]) => a.map((v) => v / (len(a) || 1));
114
+ const fmt = (v?: number[]) => (v ? `[${v.map((n) => Number(n.toFixed(2))).join(", ")}]` : "—");
115
+ const mm = (n: number) => `${Number(n.toFixed(2))} mm`;
116
+
117
+ /** Lateral offset of a point from an axis, and the angle between two directions. */
118
+ export function axisOffset(axis: GeometryFrame, point: GeometryFrame): { lateral: number; along: number; angleDeg: number } {
119
+ const n = unit(axis.normal);
120
+ const d = sub(point.origin, axis.origin);
121
+ const along = dot(d, n);
122
+ const lateral = len(sub(d, n.map((v) => v * along)));
123
+ const c = Math.max(-1, Math.min(1, dot(n, unit(point.normal))));
124
+ return { lateral, along, angleDeg: (Math.acos(c) * 180) / Math.PI };
125
+ }
126
+
127
+ /** Alignment tolerance between a feature's axis and the part it serves (assumption: printed-hole positional tolerance). */
128
+ export const ALIGN_MM = 0.3;
129
+ export const ALIGN_DEG = 2;
130
+
131
+ /** The generator's own files: the sourceDigest of a design covers them and the job it was built from. */
132
+ export const GENERATOR_FILES = ["enclosure_cad.py", "fit.py"].map((f) => fileURLToPath(new URL(`../py/${f}`, import.meta.url)));
133
+
134
+ /**
135
+ * The sourceDigest the generator here would record for a job: sha256 over
136
+ * py/enclosure_cad.py, py/fit.py and `json.dumps(job, sort_keys=True)`, as
137
+ * enclosure_cad.py computes it.
138
+ */
139
+ export function generatorSourceDigest(job: unknown, files: string[] = GENERATOR_FILES): string {
140
+ const h = createHash("sha256");
141
+ for (const f of files) h.update(readFileSync(f));
142
+ return h.update(pyJsonDumps(job)).digest("hex");
143
+ }
144
+
145
+ /** What changed between the job the design was generated from and the current one. */
146
+ export function jobChanges(was: Job, now: Job): string[] {
147
+ const out: string[] = [];
148
+ const s = (x: unknown) => JSON.stringify(x);
149
+ if (was.process !== now.process) out.push(`process ${was.process} → ${now.process}`);
150
+ if (was.material !== now.material) out.push(`material ${was.material} → ${now.material}`);
151
+ for (const k of Object.keys(now.design) as (keyof Job["design"])[]) if (s(was.design[k]) !== s(now.design[k])) out.push(`design ${k}`);
152
+ const wc = new Map(was.contents.map((c) => [c.key, c]));
153
+ const nc = new Map(now.contents.map((c) => [c.key, c]));
154
+ for (const [k, c] of nc) {
155
+ const w = wc.get(k);
156
+ if (!w) out.push(`${k} added inside`);
157
+ else if (w.digest !== c.digest) out.push(`${k} body changed`);
158
+ else if (s(w.matrix) !== s(c.matrix)) out.push(`${k} moved`);
159
+ }
160
+ for (const k of wc.keys()) if (!nc.has(k)) out.push(`${k} no longer inside`);
161
+ const wf = new Map(was.features.map((f) => [f.id, f]));
162
+ for (const f of now.features) {
163
+ const w = wf.get(f.id);
164
+ if (!w) out.push(`feature ${f.id} added`);
165
+ else if (s(w) !== s(f)) out.push(`feature ${f.id} changed${s(w.served) !== s(f.served) ? " (what it serves moved)" : ""}`);
166
+ }
167
+ for (const id of wf.keys()) if (!now.features.some((f) => f.id === id)) out.push(`feature ${id} removed`);
168
+ return out;
169
+ }
170
+
171
+ export interface CheckInput {
172
+ p: Project;
173
+ def: ModuleDef;
174
+ native: string;
175
+ config: string;
176
+ /** The job as the project stands now. */
177
+ current: BuiltJob;
178
+ /** Fit analysis of the current contents against the built parts (when the design is stale); undefined when it could not run. */
179
+ refit?: () => Promise<Fit | { error: string }> | Fit | { error: string };
180
+ }
181
+
182
+ export async function checkEnclosure(c: CheckInput): Promise<Diagnostic[]> {
183
+ const { p, def } = c;
184
+ const out: Diagnostic[] = [];
185
+ const paths = nativePaths(c.native, def.id);
186
+ const src = p.definitions.get(def.id)?.origin.source ?? { file: c.config };
187
+ const ent = (iface?: string) => (iface ? { kind: "interface" as const, id: `${def.id}:${iface}` } : { kind: "definition" as const, id: def.id });
188
+ const push = (code: string, severity: Diagnostic["severity"], message: string, iface?: string, source = src) => out.push({ code, severity, entity: ent(iface), message, source });
189
+
190
+ for (const x of readFeatures(def).problems) push("ENCLOSURE_FEATURE_INVALID", "error", `${def.id}.${x.interface}: ${x.message}`, x.interface);
191
+ for (const x of c.current.input) push("ENCLOSURE_INPUT", "error", x);
192
+
193
+ const report = readJson<Report>(join(p.root, paths.report));
194
+ const recorded = readJson<Job>(join(p.root, paths.job));
195
+ if (!report || !recorded) {
196
+ push("ENCLOSURE_NOT_GENERATED", "info", `${def.id} has no generated design (${paths.report}); run \`protoboard run cad:generate --module ${def.id}\``, undefined, { file: c.config });
197
+ return out;
198
+ }
199
+ const rsrc = { file: paths.report };
200
+
201
+ // ---- drift: inputs, native edits, features and frames
202
+ const now = c.current.job;
203
+ const changes = now ? jobChanges(recorded, now) : [];
204
+ const stale = !!now && (changes.length > 0 || now.inputsDigest !== recorded.inputsDigest || recorded.inputsDigest !== report.inputsDigest);
205
+ if (now && stale) {
206
+ push("ENCLOSURE_STALE", "warning", `${def.id}: the design was generated from different inputs (${changes.join("; ") || "inputs changed"}) — the model or the assembly changed, or ${paths.job} was edited by hand; review, then \`protoboard run cad:generate --module ${def.id}\``);
207
+ }
208
+ const source = readJson<{ script: string; job: string }>(join(p.root, paths.source));
209
+ const onDisk = (f: string) => (existsSync(join(p.root, f)) ? "sha256:" + createHash("sha256").update(readFileSync(join(p.root, f))).digest("hex") : undefined);
210
+ if (source && (source.script !== onDisk(paths.script) || source.job !== onDisk(paths.job))) {
211
+ push("ENCLOSURE_NATIVE_EDITED", "info", `the native design in ${c.native}/ was edited after its last build; \`protoboard run cad:export\` rebuilds from it`, undefined, { file: source.job !== onDisk(paths.job) ? paths.job : paths.script });
212
+ }
213
+ // the generator itself: a design built from this job by today's generator records another digest (PB-809).
214
+ // Only when the job is the one it was built from: a hand-edited job is ENCLOSURE_NATIVE_EDITED above.
215
+ const jobEdited = !!source && source.job !== onDisk(paths.job);
216
+ if (!jobEdited && report.sourceDigest && generatorSourceDigest(recorded) !== report.sourceDigest) {
217
+ push(
218
+ "ENCLOSURE_GENERATOR_STALE",
219
+ "warning",
220
+ `${def.id}: the design was built by another version of the generator (@protoboardai/ext-enclosure-cad py/enclosure_cad.py or py/fit.py changed since ${paths.report} was written); run \`protoboard run cad:generate --module ${def.id}\` and \`cad:export\`, then review the diff`,
221
+ undefined,
222
+ rsrc,
223
+ );
224
+ }
225
+ const generated = report.features;
226
+ for (const f of readFeatures(def).features) {
227
+ const g = generated[f.id];
228
+ if (!g || !g.parts?.length) {
229
+ push("ENCLOSURE_FEATURE_MISSING", "error", `${def.id}.${f.id} (${f.kind}) has no generated feature${g ? " (it produced no faces)" : ""}; regenerate`, f.id);
230
+ continue;
231
+ }
232
+ if (f.frame && g.frame) {
233
+ const d = [
234
+ len(sub(f.frame.origin, g.frame.origin)) > 0.01 && `origin ${fmt(f.frame.origin)} vs generated ${fmt(g.frame.origin)}`,
235
+ len(sub(unit(f.frame.normal), unit(g.frame.normal))) > 1e-3 && `normal ${fmt(f.frame.normal)} vs generated ${fmt(g.frame.normal)}`,
236
+ ].filter(Boolean);
237
+ if (d.length) push("ENCLOSURE_FRAME_MISMATCH", "error", `${f.id}: ${d.join("; ")} — the model and the enclosure disagree; review, then re-bind the frame`, f.id);
238
+ }
239
+ }
240
+ for (const id of Object.keys(generated)) {
241
+ if (!def.interfaces.some((i) => i.id === id)) push("ENCLOSURE_FEATURE_MISSING", "error", `the generated design has feature ${id}, which ${def.id} does not have (renamed or removed? regenerate)`, undefined, rsrc);
242
+ }
243
+ for (const m of report.problems) {
244
+ const id = m.split(":")[0];
245
+ push("ENCLOSURE_GENERATOR", "error", `${def.id}: ${m}`, def.interfaces.some((i) => i.id === id) ? id : undefined, rsrc);
246
+ }
247
+
248
+ // ---- walls (exact, per feature)
249
+ for (const w of report.walls) {
250
+ if (w.mm < w.min_mm - 1e-6) push("ENCLOSURE_THIN_WALL", "error", `${w.feature}: ${w.what} is ${mm(w.mm)}, below the ${mm(w.min_mm)} minimum`, def.interfaces.some((i) => i.id === w.feature) ? w.feature : undefined);
251
+ }
252
+
253
+ // ---- fit: recorded at generation, or re-analysed against what is inside now
254
+ let fit: Fit | undefined = report.fit;
255
+ let fitNote = "";
256
+ if (stale && c.refit) {
257
+ const r = await c.refit();
258
+ if ("error" in r) {
259
+ push("ENCLOSURE_FIT_UNCHECKED", "warning", `${def.id}: what goes inside changed and the fit could not be re-analysed (${r.error}); the results below are from generation`);
260
+ fitNote = " (at generation)";
261
+ } else {
262
+ fit = r;
263
+ fitNote = " (re-analysed against the current contents)";
264
+ }
265
+ }
266
+ const res = fit.resolution_mm;
267
+ const basis = (b: { basis?: string }, r = res) => (b.basis === "exact" ? "exact" : `±${r} mm voxels`);
268
+ for (const b of fit.bodies) {
269
+ if ((b.outside_cavity_mm ?? 0) > 1e-3) {
270
+ push("ENCLOSURE_NOT_CONTAINED", "error", `${b.key} (${b.label}) reaches ${mm(b.outside_cavity_mm!)} outside the cavity at ${fmt(b.outside_at)}${fitNote}`);
271
+ }
272
+ if (b.status === "collision" && (b.min_clearance_mm ?? 0) < 0) {
273
+ push("ENCLOSURE_COLLISION", "error", `${b.key} (${b.label}) intersects the ${b.near} by ${mm(-(b.min_clearance_mm ?? 0))} at ${fmt(b.at)}${fitNote} (${basis(b)})`);
274
+ } else if (b.status === "tight") {
275
+ push("ENCLOSURE_CLEARANCE", "warning", `${b.key} (${b.label}) is ${mm(b.min_clearance_mm ?? 0)} from the ${b.near} at ${fmt(b.at)}, under the ${mm(recorded.design.clearance)} design clearance${fitNote} (${basis(b)})`);
276
+ }
277
+ }
278
+ // plungers: against the walls exactly (at generation: the walls are what was built), against what is inside now
279
+ const wallOverlap = new Map((report.fit.plungers ?? []).map((x) => [x.key, x.wall_overlap_mm3 ?? {}]));
280
+ for (const x of fit.plungers ?? []) {
281
+ for (const [part, v] of Object.entries(wallOverlap.get(x.key) ?? {})) {
282
+ if (v > 1e-3) push("ENCLOSURE_COLLISION", "error", `${x.key} intersects the ${part} (${Number(v.toFixed(3))} mm³, exact): it cannot be assembled`, x.feature);
283
+ }
284
+ const r = x.resolution_mm ?? res;
285
+ if (x.status === "collision" && (x.min_clearance_mm ?? 0) < 0) push("ENCLOSURE_COLLISION", "error", `${x.key} intersects ${x.near} by ${mm(-(x.min_clearance_mm ?? 0))} at ${fmt(x.at)}${fitNote} (±${r} mm voxels)`, x.feature);
286
+ else if (x.status === "tight" && x.near) push("ENCLOSURE_CLEARANCE", "warning", `${x.key} is ${mm(x.min_clearance_mm ?? 0)} from ${x.near} at ${fmt(x.at)}, under the ${mm(recorded.design.clearance)} design clearance${fitNote} (±${r} mm voxels)`, x.feature);
287
+ }
288
+ for (const [part, w] of Object.entries(fit.walls ?? {})) {
289
+ if (w.min_mm < recorded.design.min_wall - res) push("ENCLOSURE_THIN_WALL", "warning", `${part}: a wall about ${mm(w.min_mm)} thick at ${fmt(w.at)}, below the ${mm(recorded.design.min_wall)} minimum (voxel estimate, ±${res} mm)`, undefined, rsrc);
290
+ }
291
+
292
+ // ---- features against what they serve, now
293
+ const servedNow = new Map((now ?? recorded).features.map((f) => [f.id, f.served]));
294
+ for (const [id, g] of Object.entries(generated)) {
295
+ const served = servedNow.get(id);
296
+ const spec = recorded.features.find((f) => f.id === id);
297
+ if (served && g.frame && ["opening", "window", "light_pipe", "button"].includes(g.kind)) {
298
+ const a = axisOffset(g.frame, served.frame);
299
+ if (a.lateral > ALIGN_MM || a.angleDeg > ALIGN_DEG) {
300
+ push("ENCLOSURE_MISALIGNED", "error", `${id} is ${mm(a.lateral)} / ${a.angleDeg.toFixed(1)}° off the axis of ${served.label} (allowed ${ALIGN_MM} mm / ${ALIGN_DEG}°)`, id);
301
+ }
302
+ const recess = -a.along;
303
+ const max = spec?.params.max_recess_mm as number | undefined;
304
+ if (g.kind === "opening" && max !== undefined && recess > max + 1e-6) push("ENCLOSURE_RECESS", "error", `${served.label} sits ${mm(recess)} behind the outer surface of ${id}; at most ${mm(max)} is allowed`, id);
305
+ if (g.kind === "button") {
306
+ const tip = g.tip as number[];
307
+ const gap = dot(sub(tip, served.frame.origin), unit(g.frame.normal));
308
+ const travel = (g.travel_mm as number | undefined) ?? 0;
309
+ const reach = (spec?.params.protrude_mm as number | undefined) ?? 1.0;
310
+ if (gap < -1e-3) push("ENCLOSURE_PLUNGER", "error", `${id}: the plunger presses ${served.label} by ${mm(-gap)} at rest`, id);
311
+ else if (gap + travel > reach + 1e-6) push("ENCLOSURE_PLUNGER", "error", `${id}: ${mm(gap)} gap + ${mm(travel)} travel is more than the ${mm(reach)} the plunger stands out; it cannot press ${served.label}`, id);
312
+ }
313
+ }
314
+ if (g.kind === "button" && g.flange_hold_pct !== undefined) {
315
+ const rule = g.flange_hold_rule as { overlap_mm: number; pct: number };
316
+ const held = g.flange_hold_pct as number;
317
+ const by = (g.flange_trimmed_by as string[] | undefined) ?? [];
318
+ if (held < rule.pct - 1e-6) {
319
+ push("ENCLOSURE_PLUNGER", "error", `${id}: ${by.length ? `trimmed to clear ${by.join(", ")}, ` : ""}the plunger's flange overlaps the wall by ${mm(rule.overlap_mm)} over only ${held.toFixed(0)} % of its edge (it needs ${rule.pct} % to stay in); move the switch away from what is near it, or retain the plunger another way`, id);
320
+ }
321
+ }
322
+ if (g.kind === "opening" && g.plug) {
323
+ const pf = g.plug as { name?: string; passes: boolean; plug_mm: number[]; overmold_mm?: number[]; seat_mm?: number; seat_gap_mm?: number; edge_overhang_mm?: number; seat?: { proud_mm: number } };
324
+ const name = pf.name ?? "the plug";
325
+ const size = g.size_mm as number[];
326
+ if (!pf.passes) push("ENCLOSURE_PLUG_FIT", "error", `${id}: ${name} (${pf.plug_mm.join(" × ")} mm) does not pass through the ${size.join(" × ")} mm opening`, id);
327
+ if (pf.seat_mm !== undefined && pf.seat_mm < -1e-6) push("ENCLOSURE_PLUG_FIT", "warning", `${id}: the overmold of ${name} (${pf.overmold_mm?.join(" × ")} mm) overhangs the flat face around the opening by ${mm(-pf.seat_mm)}; it will not seat against the wall`, id);
328
+ if (pf.seat_gap_mm !== undefined && pf.seat_gap_mm > ALIGN_MM) push("ENCLOSURE_PLUG_FIT", "warning", `${id}: the curved wall leaves ${mm(pf.seat_gap_mm)} under the ends of the overmold of ${name}; flatten a seat for it (Opening seat: "flat")`, id);
329
+ if (pf.edge_overhang_mm !== undefined && pf.edge_overhang_mm > ALIGN_MM) push("ENCLOSURE_PLUG_FIT", "info", `${id}: the overmold of ${name} reaches ${mm(pf.edge_overhang_mm)} past the case's front or back face; it seats on the face and overhangs the edge`, id);
330
+ }
331
+ if (g.kind === "seal_gland" && spec) {
332
+ // against the cited table for this cross-section, not the copy in the job (which a hand edit moves with the groove)
333
+ const w = g.w_mm as number;
334
+ const table = glandRow(g.type as "face" | "radial", w);
335
+ const depth = (g.gland_depth_mm as number) + (g.gap_mm as number);
336
+ const off = (g.rod_offset_mm as number | undefined) ?? 0;
337
+ const squeezes = [depth - off, depth + off].map((d) => ((w - d) / w) * 100);
338
+ const [lo, hi] = [Math.min(...squeezes), Math.max(...squeezes)];
339
+ const basisNote = g.gap_basis ? `; ${g.gap_basis}` : "";
340
+ const range = off > 0 ? `${lo.toFixed(1)}–${hi.toFixed(1)} %` : `${lo.toFixed(1)} %`;
341
+ // a printed bore drawn oversize: if it prints at its drawn size the gland is deeper and the collar freer
342
+ const allow = (g.bore_allowance_mm as number | undefined) ?? 0;
343
+ if (table && allow > 0) {
344
+ const s = [depth + allow - off - allow, depth + allow + off + allow].map((d) => ((w - d) / w) * 100);
345
+ push("ENCLOSURE_SEAL_SQUEEZE", "info", `${id}: the skirt's bore is drawn ${mm(allow)} (radial) over the Ø${g.printed_bore_d_mm} it should print at; if it prints at its drawn Ø${g.bore_d_mm}, ${g.oring} is squeezed ${Math.min(...s).toFixed(1)}–${Math.max(...s).toFixed(1)} % (${table.squeeze_pct[0]}–${table.squeeze_pct[1]} % wanted): measure the printed bore on the first print`, id);
346
+ }
347
+ if (!table) push("ENCLOSURE_SEAL_SQUEEZE", "error", `${id}: no ${g.type} gland row for a ${w} mm cross-section, so the squeeze cannot be checked`, id);
348
+ else if (lo < table.squeeze_pct[0] - 0.05 || hi > table.squeeze_pct[1] + 0.05) {
349
+ push("ENCLOSURE_SEAL_SQUEEZE", "error", `${id}: ${g.oring} squeezed ${range}, outside ${table.squeeze_pct[0]}–${table.squeeze_pct[1]} % (${table.source})${basisNote}`, id);
350
+ }
351
+ if (g.type === "face") {
352
+ const ext = g.pressure !== "internal";
353
+ const ratio = ext ? (g.groove_inner_perimeter_mm as number) / (g.oring_inner_perimeter_mm as number) - 1 : 1 - (g.groove_outer_perimeter_mm as number) / (g.oring_outer_perimeter_mm as number);
354
+ const pct = ratio * 100;
355
+ if (ext && pct > MAX_INSTALLED_STRETCH.pct) push("ENCLOSURE_SEAL_FIT", "error", `${id}: the groove stretches ${g.oring} ${pct.toFixed(1)} % (more than ${MAX_INSTALLED_STRETCH.pct} %, ${MAX_INSTALLED_STRETCH.source}); choose a larger O-ring or a smaller groove`, id);
356
+ else if (pct < -1e-6 || pct > FACE_GROOVE_FIT.pct + 1e-6) {
357
+ push("ENCLOSURE_SEAL_FIT", "warning", `${id}: the groove's ${ext ? "inside" : "outside"} perimeter is ${pct >= 0 ? "+" : ""}${pct.toFixed(1)} % from ${g.oring}'s ${ext ? "I.D." : "O.D."} (${FACE_GROOVE_FIT.source}: within ${ext ? "+" : "−"}${FACE_GROOVE_FIT.pct} % for ${ext ? "external" : "internal"} pressure)`, id);
358
+ }
359
+ } else {
360
+ const o = spec.params.oring as { id_mm: number };
361
+ // a rod seal sits on its rod; a twist lid's collar seal on its groove's bottom
362
+ const seat = (g.seat_d_mm as number | undefined) ?? (g.rod_d_mm as number);
363
+ const what = g.seat_d_mm !== undefined ? "the collar's groove" : "the rod";
364
+ const stretch = (seat / o.id_mm - 1) * 100;
365
+ if (stretch > MAX_INSTALLED_STRETCH.pct) push("ENCLOSURE_SEAL_FIT", "error", `${id}: ${what} stretches ${g.oring} ${stretch.toFixed(1)} % (more than ${MAX_INSTALLED_STRETCH.pct} %, ${MAX_INSTALLED_STRETCH.source})`, id);
366
+ else if (g.seat_d_mm !== undefined && stretch < -1e-6) push("ENCLOSURE_SEAL_FIT", "error", `${id}: ${what} (Ø${seat}) is smaller than ${g.oring}'s I.D. (${o.id_mm} mm): the O-ring would sit loose in its groove; choose a smaller O-ring`, id);
367
+ }
368
+ }
369
+ }
370
+
371
+ // ---- envelope
372
+ const env = def.traits?.find((t) => t.type === "design_envelope")?.params?.max_mm as { length?: number; width?: number; height?: number } | undefined;
373
+ if (env && report.overall) {
374
+ const size = sub(report.overall.max, report.overall.min);
375
+ const over = [env.length !== undefined && size[0] > env.length + 1e-6 && `length ${mm(size[0])} > ${mm(env.length)}`, env.width !== undefined && size[1] > env.width + 1e-6 && `width ${mm(size[1])} > ${mm(env.width)}`, env.height !== undefined && size[2] > env.height + 1e-6 && `height ${mm(size[2])} > ${mm(env.height)}`].filter(Boolean);
376
+ if (over.length) push("ENCLOSURE_ENVELOPE", "error", `${def.id} is bigger than its design envelope: ${over.join(", ")}`);
377
+ }
378
+ return out;
379
+ }