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.
- package/bin/cli.js +12 -2
- package/docs/AUTHORING-PARTS.md +304 -1
- package/docs/KERNEL-CONTRACT.md +40 -10
- package/package.json +1 -1
- package/src/framework/geometry/arc-fit.js +169 -36
- package/src/framework/geometry/arc-math.js +15 -7
- package/src/framework/geometry/contour-offset.js +132 -5
- package/src/framework/geometry/polygon.js +7 -0
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-schema.js +2 -1
- package/src/framework/lint/rules-shape.js +10 -3
- package/src/framework/lint/rules-sheet.js +172 -0
- package/src/framework/lint/rules-verify.js +10 -0
- package/src/framework/oracle/gates.js +13 -0
- package/src/framework/oracle/measure.js +129 -5
- package/src/framework/oracle/verify.js +113 -5
- package/src/framework/process/laser/descriptor.js +1089 -0
- package/src/framework/process/registry.js +24 -0
- package/src/framework/sheet/constants.js +104 -0
- package/src/framework/sheet/joinery.js +356 -0
- package/src/framework/sheet/part.js +98 -0
- package/src/framework/sheet/pose.js +114 -0
- package/src/framework/sheet/resolve.js +133 -0
- package/src/framework/verify-metrics.js +8 -1
- package/src/parts/laser-box.js +108 -0
- package/types/geometry.d.ts +115 -0
- package/types/lint.d.ts +4 -1
- package/types/oracle.d.ts +1 -1
- package/types/part.d.ts +57 -1
- package/types/testing.d.ts +81 -1
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
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) {
|