partforge 0.132.1 → 0.133.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.
@@ -0,0 +1,172 @@
1
+ // Group 13 — sheet parts: sub-parts made with sheetPart() (partforge/geometry). A
2
+ // sheet part is recognised ONLY by its plain-data `sheet` declaration
3
+ // (isSheetPart), never by identity: in partforge-cloud's part worker the part's
4
+ // partforge/geometry and this linter are separate module instances, so an
5
+ // instanceof or a module-scoped Symbol would silently never match there.
6
+ //
7
+ // Every finding carries the guide section's id as `pattern` (SHEET_DOC_ID), and a
8
+ // hint that stands on its own in at most 500 characters — cloud's sanitizers cut
9
+ // message and hint there, and an agent whose copy of the guide was pruned from its
10
+ // history still has both. Error or warning only, never a note: cloud's lint
11
+ // sanitizer drops notes. The process registry adds each process's own rules
12
+ // (PROCESS_LINT_RULES: the laser thickness range today); these seven hold for any
13
+ // sheet process.
14
+ import { err, warn } from "./finding.js";
15
+ import { collectDescriptors } from "./rules-schema.js";
16
+ import { SHEET_DOC_ID, isSheetPart, fmtMm } from "../sheet/constants.js";
17
+ import { validatePose } from "../sheet/pose.js";
18
+ import { PROCESS_IDS, PROCESS_LINT_RULES } from "../process/registry.js";
19
+
20
+ const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
21
+ const partEntries = (part) => (isPlainObject(part?.parts) ? Object.entries(part.parts) : []);
22
+ const messageOf = (e) => e?.message || String(e);
23
+ // A number prints as itself (NaN and Infinity included, which JSON would call null);
24
+ // anything else as JSON, which quotes a string.
25
+ const show = (v) => (typeof v === "number" ? String(v) : v === undefined ? "undefined" : JSON.stringify(v));
26
+
27
+ // What is wrong with a `sheet` declaration's SHAPE, or null. sheetPart() validates
28
+ // every field at module load, so this only ever fires on a hand-written or
29
+ // hand-edited declaration.
30
+ function sheetDeclarationProblem(sheet) {
31
+ if (!isPlainObject(sheet)) return "sheet must be a plain object";
32
+ if (!PROCESS_IDS.includes(sheet.process)) return `process must be one of: ${PROCESS_IDS.join(", ")}`;
33
+ if (!(typeof sheet.material === "function" || (typeof sheet.material === "string" && sheet.material.trim() !== ""))) {
34
+ return "material must be a non-empty string or a (p, d) => string function";
35
+ }
36
+ if (!(typeof sheet.thickness === "function" || typeof sheet.thickness === "number")) {
37
+ return "thickness must be a number or a (p, d) => number function";
38
+ }
39
+ if (typeof sheet.profile !== "function") return "profile must be a (k, p, d) function";
40
+ if (sheet.score != null && typeof sheet.score !== "function") return "score must be a (k, p, d) function or null";
41
+ if (sheet.engrave != null && typeof sheet.engrave !== "function") return "engrave must be a (k, p, d) function or null";
42
+ if (!(sheet.pose == null || typeof sheet.pose === "function" || isPlainObject(sheet.pose))) {
43
+ return "pose must be a { face, up, at } object, a (p, d) function, or null";
44
+ }
45
+ return null;
46
+ }
47
+
48
+ // The sheet sub-parts whose VALUES the rules below judge: recognised, buildable, and
49
+ // well-formed. A malformed declaration is sheet-invalid's one error; judging its
50
+ // values as well would report one cause twice.
51
+ const judgedSheets = (part) => partEntries(part).filter(([, sp]) =>
52
+ isSheetPart(sp) && typeof sp.build === "function" && sheetDeclarationProblem(sp.sheet) === null);
53
+
54
+ // `thickness` at lint's params: { value } or { threw }.
55
+ function thicknessAt(sheet, p, d) {
56
+ try { return { value: typeof sheet.thickness === "function" ? sheet.thickness(p, d) : sheet.thickness }; }
57
+ catch (e) { return { threw: messageOf(e) }; }
58
+ }
59
+ const validThickness = (v) => typeof v === "number" && Number.isFinite(v) && v > 0;
60
+
61
+ // The keys of `p` and `d` a thickness function reads, through recording proxies; null
62
+ // when it throws (sheet-thickness-invalid's business).
63
+ function thicknessReads(fn, p, d) {
64
+ const read = new Set();
65
+ const spy = (obj, tag) => new Proxy(obj ?? {}, {
66
+ get(target, key, receiver) {
67
+ if (typeof key === "string") read.add(`${tag}.${key}`);
68
+ return Reflect.get(target, key, receiver);
69
+ },
70
+ });
71
+ try { fn(spy(p, "p"), spy(d, "d")); } catch { return null; }
72
+ return read;
73
+ }
74
+
75
+ const HINTS = {
76
+ invalid: "Build laser-cut parts with `sheetPart({ material, thickness, profile, … })` from partforge/geometry instead of writing `sheet` by hand — it validates every field and supplies build and place. See \"Sheet parts\" in the authoring guide.",
77
+ thickness: "Bind thickness to a measured-value control, e.g. `thickness: (p) => p.t` with a `t` number control defaulting to the measured sheet thickness (plywood sold as 3 mm is often 2.7–3.3).",
78
+ pose: "A pose is { face, up, at }: face is the laser face's outward normal and up is the drawing's +y, each an axis word (+X, -X, +Y, -Y, +Z, -Z), perpendicular to each other; at is where drawing [0, 0] lands. A box's front panel: { face: \"-Y\", up: \"+Z\", at: [-W/2, -D/2, 0] }. fingerBox() computes every panel's pose.",
79
+ literal: "Sheet stock varies ±0.3–0.5 mm from its nominal size; bind thickness to the measured-value control (thickness: (p) => p.t, with a t control the user sets to what they measured) so every joint fits the sheet they actually have.",
80
+ kerf: "Kerf is chosen when the cut & print kit is downloaded (it offsets the cut lines only), so a kerf control double-counts it. Use a clearance control instead: joinery clearance is the finished joint's total play.",
81
+ customBuild: "The cut file is drawn from profile, score and engrave, never from build, so a custom build can drift from what gets cut; verify compares its volume with the profile (sheetSolidMatch). Change the profile, not the solid.",
82
+ sheetsOnly: "A process profile checks 3D-printed parts (bed fit, min wall); sheet parts get the laser checks automatically. Remove verify.process, or keep it only when the forge also has printed parts. There is no \"laser\" profile.",
83
+ };
84
+
85
+ export const SHEET_RULES = [
86
+ {
87
+ // Skipped when `build` is missing: no-buildable-parts owns that case, and its hint
88
+ // already points at sheetPart(), so one cause never gives two blocking errors.
89
+ id: "sheet-invalid",
90
+ run: ({ part }) => partEntries(part)
91
+ .filter(([, sp]) => isPlainObject(sp) && sp.sheet !== undefined && typeof sp.build === "function")
92
+ .flatMap(([name, sp]) => {
93
+ const reason = sheetDeclarationProblem(sp.sheet);
94
+ return reason ? [err("sheet-invalid", `sub-part "${name}" has a malformed \`sheet\` declaration: ${reason}`,
95
+ HINTS.invalid, `parts.${name}.sheet`, SHEET_DOC_ID)] : [];
96
+ }),
97
+ },
98
+ {
99
+ id: "sheet-thickness-invalid",
100
+ run: ({ part, p, d, deriveError }) => (deriveError ? [] : judgedSheets(part).flatMap(([name, sp]) => {
101
+ const t = thicknessAt(sp.sheet, p, d);
102
+ if ("threw" in t) {
103
+ return [err("sheet-thickness-invalid", `sub-part "${name}": sheet thickness threw at the defaults: ${t.threw}`,
104
+ HINTS.thickness, `parts.${name}.sheet.thickness`, SHEET_DOC_ID)];
105
+ }
106
+ return validThickness(t.value) ? [] : [err("sheet-thickness-invalid",
107
+ `sub-part "${name}": sheet thickness is ${show(t.value)} at the defaults — it must be a finite number above 0 (mm)`,
108
+ HINTS.thickness, `parts.${name}.sheet.thickness`, SHEET_DOC_ID)];
109
+ })),
110
+ },
111
+ {
112
+ // A pose function that throws, or returns nothing, is left to the build rules:
113
+ // the generated build resolves the pose, so build-throws already names it.
114
+ id: "sheet-pose-invalid",
115
+ run: ({ part, p, d, deriveError }) => (deriveError ? [] : judgedSheets(part).flatMap(([name, sp]) => {
116
+ const raw = sp.sheet.pose;
117
+ if (raw == null) return [];
118
+ let pose;
119
+ try { pose = typeof raw === "function" ? raw(p, d) : raw; } catch { return []; }
120
+ if (pose == null) return [];
121
+ const reason = validatePose(pose);
122
+ return reason ? [err("sheet-pose-invalid", `sub-part "${name}": sheet pose is invalid at the defaults — ${reason}`,
123
+ HINTS.pose, `parts.${name}.sheet.pose`, SHEET_DOC_ID)] : [];
124
+ })),
125
+ },
126
+ {
127
+ id: "sheet-thickness-literal",
128
+ run: ({ part, p, d, deriveError }) => (deriveError ? [] : judgedSheets(part).flatMap(([name, sp]) => {
129
+ const t = thicknessAt(sp.sheet, p, d);
130
+ if ("threw" in t || !validThickness(t.value)) return []; // sheet-thickness-invalid's
131
+ if (typeof sp.sheet.thickness === "function") {
132
+ const read = thicknessReads(sp.sheet.thickness, p, d);
133
+ if (read === null || read.size > 0) return [];
134
+ }
135
+ return [warn("sheet-thickness-literal", `sub-part "${name}": sheet thickness is a fixed number (${fmtMm(t.value)} mm)`,
136
+ HINTS.literal, `parts.${name}.sheet.thickness`, SHEET_DOC_ID)];
137
+ })),
138
+ },
139
+ {
140
+ id: "sheet-kerf-control",
141
+ run: ({ part }) => {
142
+ if (!partEntries(part).some(([, sp]) => isSheetPart(sp))) return [];
143
+ return collectDescriptors(part)
144
+ .filter(({ d: c, container }) => !container && c && [c.key, c.label].some((v) => typeof v === "string" && /kerf/i.test(v)))
145
+ .map(({ d: c, path }) => warn("sheet-kerf-control", `control "${c.key ?? c.label}" looks like a kerf setting`,
146
+ HINTS.kerf, path, SHEET_DOC_ID));
147
+ },
148
+ },
149
+ {
150
+ id: "sheet-custom-build",
151
+ run: ({ part }) => judgedSheets(part)
152
+ .filter(([, sp]) => sp.build !== sp.sheet.generatedBuild)
153
+ .map(([name]) => warn("sheet-custom-build", `sub-part "${name}" replaces the build sheetPart generated`,
154
+ HINTS.customBuild, `parts.${name}.build`, SHEET_DOC_ID)),
155
+ },
156
+ {
157
+ // A process id ("laser") is verify-unknown-process's error, with a sheet hint of
158
+ // its own — not also this warning.
159
+ id: "verify-process-sheets-only",
160
+ run: ({ part }) => {
161
+ const process = part?.verify?.process;
162
+ if (!process) return [];
163
+ if (typeof process === "string" && PROCESS_IDS.includes(process)) return [];
164
+ const exportable = partEntries(part).filter(([, sp]) => isPlainObject(sp) && sp.exportable !== false);
165
+ if (exportable.length === 0 || !exportable.every(([, sp]) => isSheetPart(sp))) return [];
166
+ const named = typeof process === "string" ? `"${process}"` : "an inline profile";
167
+ return [warn("verify-process-sheets-only", `\`verify.process\` is ${named}, but every exportable part is a sheet part`,
168
+ HINTS.sheetsOnly, "verify.process", SHEET_DOC_ID)];
169
+ },
170
+ },
171
+ ...PROCESS_LINT_RULES,
172
+ ];
@@ -7,6 +7,8 @@ import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
7
7
  import { PROFILES, ORIENTATIONS } from "../oracle/dfm-profiles.js";
8
8
  import { parseAssertion } from "../oracle/assert-dsl.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
+ import { PROCESS_IDS } from "../process/registry.js";
11
+ import { SHEET_DOC_ID } from "../sheet/constants.js";
10
12
 
11
13
  // Resolve `expect` to a plain object. The function form (p, d) => ({…}) is invoked
12
14
  // once with the probe's params so per-preset topology can be linted like any other.
@@ -157,6 +159,14 @@ function checkProcessSpec(spec, path, valid, seen, depth = 0) {
157
159
  if (depth > 50) return []; // pathological chain — bail rather than hang
158
160
  if (typeof spec === "string") {
159
161
  if (valid.includes(spec)) return [];
162
+ // A manufacturing-process id ("laser") is not a DFM profile: sheet parts get their
163
+ // checks from sheetPart(), and verify.process is for printing.
164
+ if (PROCESS_IDS.includes(spec)) {
165
+ return [err("verify-unknown-process",
166
+ `\`${path}\` names "${spec}", which is not a known DFM profile`,
167
+ `Laser-cut parts need no profile: build them with \`sheetPart()\` and the laser checks apply automatically (see "Sheet parts" in the authoring guide). verify.process is for 3D-printed parts — use one of: ${valid.join(", ")}.`,
168
+ path, SHEET_DOC_ID)];
169
+ }
160
170
  const hint = suggest(spec, valid);
161
171
  return [err("verify-unknown-process",
162
172
  `\`${path}\` names "${spec}", which is not a known DFM profile`,
@@ -61,6 +61,19 @@ export function partGatesMinWall(part, { process, expanded } = {}) {
61
61
  }
62
62
  }
63
63
 
64
+ // Whether a verify of this part fits a process profile's print BED — the only reader of
65
+ // measure()'s print-pose sizes in a view holding a sheet part. `process` is verify's own
66
+ // override, else the part's `verify.process`. Unresolvable → true: measure the sizes
67
+ // and let verify report the bad profile.
68
+ export function partHasBed(part, { process } = {}) {
69
+ try {
70
+ const spec = process ?? part?.verify?.process;
71
+ return !!(spec && resolveProfile(spec)?.bed);
72
+ } catch {
73
+ return true;
74
+ }
75
+ }
76
+
64
77
  // The wall bands a measurement of `params` must track, per sub-part, from the part's
65
78
  // own `verify.expect` — resolved for exactly these params (a function expect may
66
79
  // change the band per case). Range form only: the membership window needs both ends.
@@ -1,12 +1,17 @@
1
1
  import { buildView } from "./build.js";
2
2
  import { cachedBVH } from "./bvh.js";
3
3
  import { assemblyOverlaps } from "../assembly.js";
4
- import { resolveParams } from "../part-model.js";
4
+ import { resolveParams, buildPosed } from "../part-model.js";
5
+ import { probeSubPartPose } from "../pose-probe-core.js";
6
+ import { composePose } from "../geometry/pose.js";
7
+ import { isSheetPart, sheetMeta, SHEET_CHECK_BUDGET_MS, MARK_DEPTH } from "../sheet/constants.js";
8
+ import { resolveSheet } from "../sheet/resolve.js";
9
+ import { processFor } from "../process/registry.js";
5
10
  import { meshGaps, pairKey, CONTACT_EPS, GAP_THRESHOLD } from "./gaps.js";
6
11
  import { bounds, meshArea, meshCentroid } from "./mesh.js";
7
12
  import { minWall, DIAGNOSTIC_SAMPLES } from "./min-wall.js";
8
13
  import { overhang } from "./overhang.js";
9
- import { partGatesMinWall, partOverhangAngle, partWallBands } from "./gates.js";
14
+ import { partGatesMinWall, partHasBed, partOverhangAngle, partWallBands } from "./gates.js";
10
15
  import { summarizeContours } from "./shape-probe.js";
11
16
 
12
17
  const size = ({ min, max }) => [max[0] - min[0], max[1] - min[1], max[2] - min[2]];
@@ -107,6 +112,98 @@ function evaluateProbes(kernel, part, params) {
107
112
  } finally { kernel.endSubPart?.(); }
108
113
  }
109
114
 
115
+ // ── sheet parts ──────────────────────────────────────────────────────────────
116
+ // A sub-part made with sheetPart() (partforge/geometry) is recognised by its
117
+ // plain-data `sheet` declaration alone (isSheetPart) — never by identity: in
118
+ // partforge-cloud's part worker the part's partforge/geometry and this oracle are
119
+ // separate module instances. Its checks are 2-D: its process's facts() reads the
120
+ // resolved profile, score and engrave through Shape2D methods. The 3-D parts are
121
+ // here — where a finding sits in the assembly, and a custom build's volume drift.
122
+
123
+ // A column-major mat4 (geometry/pose.js composePose) applied to one point.
124
+ const transformPoint = (m, [x, y, z]) => [
125
+ m[0] * x + m[4] * y + m[8] * z + m[12],
126
+ m[1] * x + m[5] * y + m[9] * z + m[13],
127
+ m[2] * x + m[6] * y + m[10] * z + m[14],
128
+ ];
129
+
130
+ // One sheet row's facts, within `budget.left` ms of 2-D work, which it spends (see
131
+ // measure()). A 2-D location is lifted through the sub-part's DISPLAY pose, as the geometry-free
132
+ // probe records it, at mid-thickness; a pose the probe cannot trust (a place() that
133
+ // queries geometry) leaves `at` null and keeps the 2-D reading. Null for a process
134
+ // id no descriptor answers to (lint's sheet-invalid reports that) is still a sheet —
135
+ // measure and verify ask the same question, isSheetPart — with nothing to check it by:
136
+ // unknownProcessFacts.
137
+ function sheetRowFacts(kernel, part, name, view, { p, d }, budget, volume) {
138
+ const sp = part.parts[name];
139
+ const proc = processFor(sp);
140
+ if (!proc) return unknownProcessFacts(kernel, sp, p, d);
141
+ const resolved = resolveSheet(kernel, sp, p, d);
142
+ const start = budget.now();
143
+ const f = proc.facts(resolved, { deadline: start + budget.left, now: budget.now });
144
+ budget.left -= budget.now() - start;
145
+ const probe = probeSubPartPose(sp, { view, purpose: "display", p, d });
146
+ const m = probe.trusted ? composePose(probe.pose) : null;
147
+ const lift = (uv) => (m && uv ? transformPoint(m, [uv[0], uv[1], f.thickness / 2]) : null);
148
+ const expected = f.area * f.thickness - MARK_DEPTH * (f.marksArea ?? 0);
149
+ return {
150
+ ...f,
151
+ at: { bridge: lift(f.at2d.bridge), gap: lift(f.at2d.gap), marks: lift(f.at2d.marks) },
152
+ solidMatchPct: f.customBuild && f.marksArea != null && expected > 1e-9
153
+ ? (100 * Math.abs(volume - expected)) / expected
154
+ : null,
155
+ };
156
+ }
157
+
158
+ // A sheet whose process no descriptor answers to: what can be read without one (the
159
+ // cut layer's size, area and pieces, when the declaration resolves at all), and no
160
+ // reading — `evaluated` false.
161
+ function unknownProcessFacts(kernel, sp, p, d) {
162
+ const meta = sheetMeta(sp, p, d);
163
+ let flat = [0, 0], area = 0, pieces = 0;
164
+ try {
165
+ const { profile } = resolveSheet(kernel, sp, p, d);
166
+ pieces = profile.toContours().length;
167
+ if (pieces) {
168
+ const { min, max } = profile.boundingBox();
169
+ flat = [max[0] - min[0], max[1] - min[1]];
170
+ area = profile.area();
171
+ }
172
+ } catch { /* a declaration that does not resolve: sizes stay zero */ }
173
+ return {
174
+ process: sp.sheet.process, material: meta?.material ?? null, thickness: meta?.thickness ?? null, group: meta?.group ?? null,
175
+ flat, area, pieces, customBuild: sp.build !== sp.sheet.generatedBuild,
176
+ marksArea: null, bridge: null, bridgeCapped: false, gap: null, gapCapped: false, marksOutside: null, solidMatchPct: null,
177
+ at2d: { bridge: null, gap: null, marks: null }, at: { bridge: null, gap: null, marks: null },
178
+ readErrors: { bridge: null, gap: null, marks: null, marksArea: null },
179
+ evaluated: false,
180
+ };
181
+ }
182
+
183
+ // The size of every PRINTED sub-part in its print (export) pose — what verify fits the
184
+ // process profile's bed to in a view holding a sheet part: laser-cut stock never meets
185
+ // a print bed, and a printed part is routinely displayed in an assembly pose that is
186
+ // not how it prints. Its own cache round, like evaluateProbes: export-pose geometry
187
+ // must not evict the view's. One part at a time: an export pose that does not build
188
+ // costs that part its size ({ error }, which verify reports), never the report.
189
+ function printPoseBboxes(kernel, part, view, built, { p, d }) {
190
+ kernel.beginSubPart?.(`oracle:print:${view}`);
191
+ try {
192
+ const out = {};
193
+ for (const { name } of built) {
194
+ const sp = part.parts[name];
195
+ if (sp?.exportable === false || isSheetPart(sp)) continue;
196
+ try {
197
+ const { min, max } = buildPosed(kernel, part, name, { purpose: "export", view, p, d }).boundingBox();
198
+ out[name] = { size: [max[0] - min[0], max[1] - min[1], max[2] - min[2]] };
199
+ } catch (e) {
200
+ out[name] = { error: String(e?.message || e).slice(0, 200) };
201
+ }
202
+ }
203
+ return out;
204
+ } finally { kernel.endSubPart?.(); }
205
+ }
206
+
110
207
  // Headless geometric report for one view of a part (Manifold-only). Reads exact
111
208
  // solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
112
209
  // the assembly overlap check plus pair gap distances (near misses are reported,
@@ -155,12 +252,29 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
155
252
  // taken against the wrong threshold is worse than none.
156
253
  const overhangAngle = opts.overhang !== undefined ? opts.overhang : partOverhangAngle(part);
157
254
  const subBounds = [];
255
+ // Sheet parts: a view holding one sizes each printed sub-part in its print pose
256
+ // (verify fits the bed per part there), and every sheet row gets its 2-D facts under
257
+ // ONE budget for the whole call, charged for 2-D work alone: each sheet's facts() is
258
+ // timed and the next is given what is left, so neither a slow build nor a printed
259
+ // row's min-wall rays between two sheets spend it (the verdict used to depend on
260
+ // declaration order). A view without a sheet part measures exactly as it always has.
261
+ const sheetView = built.some(({ name }) => isSheetPart(part.parts[name]));
262
+ const sheetParams = sheetView ? resolveParams(part, params) : null;
263
+ // The print-pose sizes are read by one check, the process bed (verify passes whether
264
+ // its profile has one; alone, measure asks the part's own profile), and each costs a
265
+ // second build of the part — so they are built only for a bed.
266
+ const measuredPrintBboxes = sheetView && (opts.printBboxes ?? partHasBed(part));
267
+ const printBboxes = measuredPrintBboxes ? printPoseBboxes(kernel, part, view, built, sheetParams) : null;
268
+ // The clock the budget runs on is Date.now, or a test's own (opts.now).
269
+ const sheetBudget = { left: opts.sheetBudgetMs ?? SHEET_CHECK_BUDGET_MS, now: opts.now ?? Date.now };
158
270
  const subparts = built.map(({ name, solid, mesh }) => {
271
+ const sheet = isSheetPart(part.parts[name]);
159
272
  const b = bounds(mesh.positions);
160
273
  subBounds.push(b);
161
274
  // Resolved lazily and only when asked for: without min-wall, a single-sub-part
162
- // view (no meshGaps) must still build no index at all.
163
- const mw = opts.minWall
275
+ // view (no meshGaps) must still build no index at all. A sheet part casts none:
276
+ // min wall is a print rule, and its laser checks read the 2-D profile instead.
277
+ const mw = opts.minWall && !sheet
164
278
  ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache), maxSamples: minWallSamples, band: wallBands[name] ?? null })
165
279
  : null;
166
280
  // One pass over the triangles, no index — cheap enough for every lap. The bed
@@ -169,7 +283,8 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
169
283
  // slab, a placeholder) is not judged. Judged in the DISPLAY pose, which is
170
284
  // what measure builds; a part whose export pose differs (a lid that prints
171
285
  // flat beside its base) is a known gap, stated in the authoring docs.
172
- const printed = part.parts[name]?.exportable !== false;
286
+ // A sheet part is laser-cut, never printed: no overhang reading for it either.
287
+ const printed = part.parts[name]?.exportable !== false && !sheet;
173
288
  const oh = overhangAngle != null && printed ? overhang(mesh, { maxAngle: overhangAngle, bedZ: b.min[2] }) : null;
174
289
  const vol = solid.volume();
175
290
  // Deviation-from-reference: only for a sub-part that declares `reference:
@@ -223,6 +338,12 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
223
338
  overhangArea: oh ? oh.area : null,
224
339
  overhangAngle: oh?.worstAngle ?? null,
225
340
  overhangAt: oh?.at ?? null,
341
+ // The process's 2-D facts on a sheet part (sheetRowFacts), null on every other.
342
+ sheet: sheet
343
+ ? sheetRowFacts(kernel, part, name, view, sheetParams, sheetBudget, vol)
344
+ : null,
345
+ ...(printBboxes?.[name]?.size ? { printBbox: printBboxes[name].size } : {}),
346
+ ...(printBboxes?.[name]?.error ? { printBboxError: printBboxes[name].error } : {}),
226
347
  };
227
348
  });
228
349
 
@@ -289,6 +410,9 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
289
410
  // Companion stamp to measuredMinWall, and read the same way: whether the pass
290
411
  // ran, said by the pass itself rather than claimed by whoever holds the result.
291
412
  measuredGaps,
413
+ // In a view holding a sheet part: whether the print-pose sizes were built (verify
414
+ // reuses a seed for a bed only when they were). Absent everywhere else.
415
+ ...(sheetView ? { measuredPrintBboxes } : {}),
292
416
  subparts,
293
417
  aggregate,
294
418
  overlaps,
@@ -6,6 +6,10 @@ import { expandExpectations, partGatesMinWall } from "./gates.js";
6
6
  import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../param-deps.js";
7
7
  import { byteAwareReplacer } from "../geometry/solid-hash.js";
8
8
  import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
9
+ import { processById } from "../process/registry.js";
10
+ import { SHEET_DOC_ID, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
11
+ // Re-exported for the callers that read them from here; their one home is sheet/constants.js.
12
+ export { SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
9
13
 
10
14
  // Re-exported for backwards compatibility: the registries moved to framework/ so
11
15
  // the linter can read the metric vocabulary without importing a geometry kernel.
@@ -160,13 +164,95 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
160
164
  if (note) out.note = note;
161
165
  if (!pass) {
162
166
  out.hint = partHint ?? reg.hint;
163
- if (reg.pattern) out.pattern = reg.pattern;
167
+ // `pattern` names an ERROR-PATTERNS.md entry; a sheet metric names the guide
168
+ // section instead (`doc: "sheet-parts"`), which rides the same field.
169
+ const pattern = reg.pattern ?? reg.doc;
170
+ if (pattern) out.pattern = pattern;
164
171
  const loc = reg.locate?.(factsObj);
165
172
  if (loc) out.location = loc;
166
173
  }
167
174
  return out;
168
175
  }
169
176
 
177
+ // ── sheet parts ──────────────────────────────────────────────────────────────
178
+ // A view holding a sheet part (sheetPart(), partforge/geometry — measure() stamps
179
+ // its row with `sheet`) is judged per part: the process profile's bed fits each
180
+ // PRINTED sub-part in its print pose (measure()'s `printBbox`), since laser-cut stock
181
+ // never meets a print bed; a sheet part gets only what the part declared for it, plus
182
+ // its process's own checks VOLUNTEERED. A volunteered check is a fact the oracle
183
+ // offers, exactly like an undeclared near miss: it warns when it fails and never
184
+ // counts toward `declared`/`evaluated` (isDeclared, in verify() below), so it can
185
+ // never make verify.ok true on its own — without that, a sheet-only part with no
186
+ // verify block would jump from "no expectations declared" to a pass.
187
+ const PRINT_POSE_NOTE = "measured in the print (export) pose";
188
+
189
+ // SHEET_CHECKS_NOTICE (sheet/constants.js) stands in for a sheet's budget-gated
190
+ // volunteered checks when its 2-D budget ran out (measure()'s `sheet.evaluated` false):
191
+ // one warning, never `unevaluated` — a check nobody declared must not withhold a verdict.
192
+
193
+ // A DECLARED sheet check the budget withheld: `unevaluated`, as a quick lap's is, so it
194
+ // withholds verify.ok — and it says how to get a verdict. Either the budget ran out
195
+ // before this check, or the profile was priced too complex to start the checks at all
196
+ // (process/laser/descriptor.js); the hint covers both, as the notice's does.
197
+ const SHEET_BUDGET_HINT = "The 2-D sheet checks did not fit their time budget — it ran out before this check, or the profile was too complex to start them — so this check has no verdict and verify.ok is withheld. What they spend time on is edges, curved ones most: round holes crowded close to each other or to an edge, and corners rounded tighter than about the sheet thickness, cost the most.";
198
+
199
+ // The print checks: measure() casts no rays and runs no overhang pass on a sheet part.
200
+ // Declared on one, each is a skip — declared, never evaluated, so a sheet-only forge
201
+ // that declares only these has no verdict rather than a vacuous pass.
202
+ const PRINT_ONLY = new Set(["minWall", "wall", "overhangArea"]);
203
+ const PRINT_ONLY_HINT = "minWall, wall and overhangArea are print checks, and a sheet part is cut, not printed: nothing measures them here. For a laser part declare sheetBridge (the narrowest web or finger) or sheetGap (the narrowest hole or slot) instead.";
204
+
205
+ // A sheet reading the geometry engine could not take (SheetFacts.readErrors): a
206
+ // volunteered check warns with the reason; a declared one warns with it too, and is
207
+ // `unevaluated`, so it withholds verify.ok exactly as a budget-withheld one does. As a bare
208
+ // skip it counted neither way, and one other declared check that passed made verify.ok true
209
+ // over a check that was never taken — the rule at the top of this file, a declared gate
210
+ // fails loudly and never skips.
211
+ // On a valid profile a read error is the checker's own limit, so the hint
212
+ // (SHEET_READ_ERROR_HINT, sheet/constants.js — the kit's README repeats it, and it stays
213
+ // one text) never blames the contour first.
214
+
215
+ // One sheet row: the declared expectations, then the process's checks the part did
216
+ // not declare, volunteered.
217
+ function sheetRowChecks(s, declaredExp) {
218
+ const out = [];
219
+ const proc = processById(s.sheet.process);
220
+ const outOfTime = s.sheet.evaluated === false;
221
+ const budgeted = (metric) => SUBPART_METRICS[metric]?.budgeted === true;
222
+ const unread = (c, metric) => (c.actual == null ? SUBPART_METRICS[metric]?.readError?.(s) ?? null : null);
223
+ const readFailure = (c, why) => ({ ...c, message: `not measured — ${why}`, hint: SHEET_READ_ERROR_HINT, pattern: SHEET_DOC_ID });
224
+ for (const [metric, expr] of Object.entries(declaredExp)) {
225
+ const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
226
+ if (PRINT_ONLY.has(metric)) {
227
+ out.push({ scope: c.scope, subpart: c.subpart, metric, kind: c.kind, expr: c.expr, actual: null, status: "skip", pass: null,
228
+ message: "not measured on a sheet part", hint: PRINT_ONLY_HINT, pattern: SHEET_DOC_ID });
229
+ } else if (!proc && c.actual == null) {
230
+ // A sheet naming no registered process (lint's sheet-invalid): nothing reads it.
231
+ out.push({ ...c, status: "skip", pass: null, message: `not measured: no "${s.sheet.process}" process`,
232
+ hint: "This sheet part names a process partforge does not know, so no sheet check reads it; lint's sheet-invalid says which. Use sheetPart() with a known process.", pattern: SHEET_DOC_ID });
233
+ } else if (outOfTime && budgeted(metric) && c.actual == null) {
234
+ out.push({ ...c, status: "skip", pass: null, unevaluated: true, message: "not evaluated (2-D check budget)",
235
+ hint: SHEET_BUDGET_HINT, pattern: SHEET_DOC_ID });
236
+ } else {
237
+ const why = unread(c, metric);
238
+ out.push(why ? readFailure({ ...c, status: "warn", pass: null, unevaluated: true }, why) : c);
239
+ }
240
+ }
241
+ let noticed = false;
242
+ for (const [metric, expr] of Object.entries(proc?.checks(s.sheet) ?? {})) {
243
+ if (Object.hasOwn(declaredExp, metric)) continue;
244
+ if (outOfTime && budgeted(metric)) {
245
+ if (!noticed) out.push({ scope: "subpart", subpart: s.name, ...SHEET_CHECKS_NOTICE });
246
+ noticed = true;
247
+ continue;
248
+ }
249
+ const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
250
+ const why = unread(c, metric);
251
+ out.push({ ...(why ? readFailure({ ...c, status: "warn", pass: null }, why) : c), volunteered: true });
252
+ }
253
+ return out;
254
+ }
255
+
170
256
  // Pure policy: profile rules + per-part expect → checks for one case's facts.
171
257
  // `overhang` is the angle the part opted into (dfm-profiles.js overhangAngleFor),
172
258
  // or null/undefined: only then does the profile's overhang rule apply, as a
@@ -176,8 +262,12 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
176
262
  // contacts/clearance are per-pair, not scalar view metrics — peel them off
177
263
  // before the registry loop and hand them to pairGapChecks.
178
264
  const { contacts, clearance, ...viewScalarExp } = expect?._view ?? {};
265
+ // No sheet part in the view: every line below runs exactly as it did before sheet
266
+ // parts existed (test/verify-golden.test.js). With one, the bed moves from the view
267
+ // to each printed sub-part — see the sheet-parts block above.
268
+ const anySheet = facts.subparts.some((s) => s.sheet);
179
269
  const viewExp = {
180
- ...(profile?.bed ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
270
+ ...(profile?.bed && !anySheet ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
181
271
  ...viewScalarExp,
182
272
  };
183
273
  for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
@@ -193,6 +283,16 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
193
283
  // is withheld rather than read as "unavailable" — which would count as answered.
194
284
  const overhangSkipped = overhang != null && (facts.measuredOverhang ?? null) === null;
195
285
  for (const s of facts.subparts) {
286
+ if (s.sheet) { checks.push(...sheetRowChecks(s, expect?.[s.name] ?? {})); continue; }
287
+ if (anySheet && profile?.bed && s.printBbox) {
288
+ checks.push({ ...check("subpart", s.name, "bbox", `<=[${profile.bed.join(",")}]`, SUBPART_METRICS, { ...s, bbox: s.printBbox }),
289
+ note: PRINT_POSE_NOTE });
290
+ } else if (anySheet && profile?.bed && s.printBboxError) {
291
+ checks.push({ scope: "subpart", subpart: s.name, metric: "bbox", kind: "gate", expr: `<=[${profile.bed.join(",")}]`,
292
+ actual: null, status: "skip", pass: null, unevaluated: true, note: PRINT_POSE_NOTE,
293
+ message: `not measured: its print (export) pose did not build — ${s.printBboxError}`,
294
+ hint: "This printed part's place() failed for purpose \"export\", so its print size — and its STL/3MF export — is unavailable. Fix place() for the export pose; the bed check then runs." });
295
+ }
196
296
  const merged = {
197
297
  ...(profile?.minWall != null ? { minWall: `>=${profile.minWall}` } : {}),
198
298
  ...(overhang != null ? { overhangArea: "<=1" } : {}),
@@ -299,7 +399,12 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
299
399
  // changes the angle therefore re-measures rather than reusing the inspect
300
400
  // job's seed, which was taken against the part's own profile.
301
401
  const overhangMatches = (seed?.result?.measuredOverhang ?? null) === (overhangAngle ?? null);
302
- if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && seed.result.view === view) {
402
+ // The print-pose half (views holding a sheet part): a seed measured without the print
403
+ // sizes cannot answer a bed, so it is admitted only when this run needs no bed.
404
+ const needPrintBboxes = profile?.bed != null;
405
+ const printBboxesMatch = !needPrintBboxes || seed?.result?.measuredPrintBboxes !== false;
406
+ if (seed?.result && (quick || seed.result.measuredMinWall || !needMinWall) && (quick || overhangMatches) && printBboxesMatch
407
+ && seed.result.view === view) {
303
408
  memo.set(signature({ ...part.defaults, ...(seed.params ?? {}) }), seed.result);
304
409
  }
305
410
 
@@ -310,7 +415,8 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
310
415
  // `probes: false` — no gate reads probe values, so re-running their booleans
311
416
  // for every case buys nothing. (A seed measured WITH probes is a superset in
312
417
  // the same way a min-wall seed is: the extra key is simply never read here.)
313
- memo.set(key, measureFn(kernel, part, view, params, { minWall: needMinWall, probes: false, overhang: overhangAngle }));
418
+ memo.set(key, measureFn(kernel, part, view, params,
419
+ { minWall: needMinWall, probes: false, overhang: overhangAngle, printBboxes: needPrintBboxes }));
314
420
  return memo.get(key);
315
421
  };
316
422
 
@@ -352,7 +458,9 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
352
458
  // deliberately NOT pushed into any case's check list — it is about the part,
353
459
  // not about `defaults`. A quick lap that withheld gates explains itself through
354
460
  // `unevaluated` and gets no notice.
355
- const isDeclared = (c) => c.scope !== "case" && c.metric !== "nearMiss";
461
+ // Volunteered checks (a sheet part's process checks, and the budget notice standing
462
+ // in for them) are facts the oracle offers, like an undeclared near miss.
463
+ const isDeclared = (c) => c.scope !== "case" && c.metric !== "nearMiss" && !c.volunteered;
356
464
  const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
357
465
  let declared = 0, evaluated = 0;
358
466
  for (const c of all) {