partforge 0.8.0 → 0.10.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 +50 -14
- package/docs/AUTHORING-PARTS.md +195 -33
- package/docs/ERROR-PATTERNS.md +168 -0
- package/package.json +3 -1
- package/skills/partforge/SKILL.md +5 -0
- package/src/framework/assembly.js +9 -3
- package/src/framework/derive.js +28 -0
- package/src/framework/geometry/kernel.js +8 -1
- package/src/framework/geometry/probe.js +6 -1
- package/src/framework/jobs.js +8 -5
- package/src/framework/mount.js +7 -1
- package/src/framework/param-deps.js +64 -5
- package/src/parts/planter.js +6 -4
- package/src/testing/bvh.js +111 -4
- package/src/testing/error-patterns.js +78 -0
- package/src/testing/gaps.js +48 -0
- package/src/testing/measure.js +21 -3
- package/src/testing/verify.js +167 -23
- package/src/testing.js +3 -0
package/src/testing/verify.js
CHANGED
|
@@ -1,49 +1,181 @@
|
|
|
1
1
|
import { parseAssertion, evaluateAssertion } from "./assert-dsl.js";
|
|
2
2
|
import { measure as defaultMeasure } from "./measure.js";
|
|
3
|
+
import { pairKey, CONTACT_EPS } from "./gaps.js";
|
|
3
4
|
import { resolveProfile } from "./dfm-profiles.js";
|
|
4
5
|
import { expandCases } from "./cases.js";
|
|
5
6
|
import { subPartReadKeys, relevanceHash, RELEVANT_ALL } from "../framework/param-deps.js";
|
|
7
|
+
import { resolveParams } from "../framework/jobs.js";
|
|
6
8
|
|
|
7
|
-
// Metric registry: name → how to pull the value out of facts,
|
|
8
|
-
// is a hard gate or a warning
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
9
|
+
// Metric registry: name → how to pull the value out of facts, whether a failure
|
|
10
|
+
// is a hard gate or a warning, and the diagnostics attached to a non-pass check:
|
|
11
|
+
// `hint` (required — the report contract promises one on every fail/warn),
|
|
12
|
+
// `pattern` (optional stable ERROR-PATTERNS.md#<id>), `locate` (optional
|
|
13
|
+
// [x,y,z] source). `manifoldOnly` facts are null on OCCT parts.
|
|
14
|
+
export const SUBPART_METRICS = {
|
|
15
|
+
holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
|
|
16
|
+
hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
|
|
17
|
+
watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight,
|
|
18
|
+
hint: "a boolean produced an open shell — check for coplanar faces or a cut that exactly grazes a surface",
|
|
19
|
+
pattern: "boolean-not-watertight" },
|
|
20
|
+
volume: { kind: "gate", extract: (s) => s.volume,
|
|
21
|
+
hint: "solid volume is out of range — a feature is missing, doubled, or a governing parameter is mis-scaled" },
|
|
22
|
+
surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea,
|
|
23
|
+
hint: "surface area is out of range — detail features (facets, ribs, textures) are missing or doubled" },
|
|
24
|
+
triangleCount: { kind: "gate", extract: (s) => s.triangleCount,
|
|
25
|
+
hint: "triangle count is out of range — tessellation quality or feature count changed unexpectedly" },
|
|
26
|
+
bbox: { kind: "gate", extract: (s) => s.bbox,
|
|
27
|
+
hint: "bounding box is out of range — check the governing dimensions and the part's orientation" },
|
|
28
|
+
minWall: { kind: "warn", extract: (s) => s.minWall,
|
|
29
|
+
hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
|
|
30
|
+
pattern: "minwall-sliver-triangles",
|
|
31
|
+
locate: (s) => s.minWallAt },
|
|
17
32
|
};
|
|
18
|
-
const VIEW_METRICS = {
|
|
19
|
-
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox
|
|
20
|
-
|
|
21
|
-
|
|
33
|
+
export const VIEW_METRICS = {
|
|
34
|
+
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
|
|
35
|
+
hint: "the assembled view exceeds its size limit — shrink the assembly or pick a process with a larger bed" },
|
|
36
|
+
volume: { kind: "gate", extract: (r) => r.aggregate.volume,
|
|
37
|
+
hint: "total assembly volume is out of range — a sub-part is missing, doubled, or mis-scaled" },
|
|
38
|
+
overlaps: { kind: "gate", extract: (r) => r.overlaps.length,
|
|
39
|
+
hint: "sub-parts interpenetrate near the reported location — adjust placement or add clearance in derive()",
|
|
40
|
+
locate: (r) => r.overlaps[0]?.location ?? null },
|
|
22
41
|
};
|
|
23
42
|
|
|
24
|
-
|
|
43
|
+
// An expectation is a bare expression (string/number/boolean) or { expr, hint }.
|
|
44
|
+
const normalizeExpectation = (spec) =>
|
|
45
|
+
spec !== null && typeof spec === "object" && !Array.isArray(spec) && "expr" in spec
|
|
46
|
+
? { expr: spec.expr, hint: spec.hint }
|
|
47
|
+
: { expr: spec, hint: undefined };
|
|
48
|
+
|
|
49
|
+
const PAIR_HINTS = {
|
|
50
|
+
contact: "the pair should touch but doesn't — grow the joining feature or move the mating datum so the faces meet",
|
|
51
|
+
clearance: "the pair's free-fit gap is out of the declared range — adjust the mating dimensions or the declared clearance",
|
|
52
|
+
nearMiss: "sub-parts nearly touch here — if they should meet, declare the pair in verify.expect._view.contacts and close the gap; if a free fit is intended, declare it under clearance",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
// Pair-wise view checks: `contacts` (must touch), `clearance` (assertion DSL vs
|
|
56
|
+
// the measured pair distance), and warnings for undeclared near misses. These are
|
|
57
|
+
// per-pair, so they live outside the scalar VIEW_METRICS registry but emit the
|
|
58
|
+
// same structured check objects.
|
|
59
|
+
function pairGapChecks(facts, { contacts, clearance }, subPartNames) {
|
|
60
|
+
const checks = [];
|
|
61
|
+
const declared = new Set();
|
|
62
|
+
const names = new Set(facts.subparts.map((s) => s.name));
|
|
63
|
+
// The part's full sub-part vocabulary (when the caller knows it): a declared
|
|
64
|
+
// name absent from THIS case's facts but present in the part is an
|
|
65
|
+
// enabled()-gated sub-part that is off for this case → skip, don't throw.
|
|
66
|
+
// A name in neither set is a typo → throw. Without the vocabulary (bare
|
|
67
|
+
// evaluateCase callers) the case's own names are the vocabulary.
|
|
68
|
+
const known = subPartNames ? new Set(subPartNames) : names;
|
|
69
|
+
const requirePair = (a, b, what) => {
|
|
70
|
+
if (a === b) throw new Error(`${what}: a pair must name two different sub-parts, got ["${a}", "${b}"]`);
|
|
71
|
+
let absent = false;
|
|
72
|
+
for (const n of [a, b]) {
|
|
73
|
+
if (names.has(n)) continue;
|
|
74
|
+
if (!known.has(n)) throw new Error(`${what}: unknown sub-part "${n}" (view has: ${[...names].join(", ")})`);
|
|
75
|
+
absent = true;
|
|
76
|
+
}
|
|
77
|
+
return absent; // true = valid pair, but a sub-part is disabled in this case
|
|
78
|
+
};
|
|
79
|
+
const gapFor = (a, b) => facts.gaps?.find((g) => pairKey(g.a, g.b) === pairKey(a, b));
|
|
80
|
+
const disabledSkip = (base) => ({ ...base, actual: null, status: "skip", pass: null, message: "sub-part disabled in this case" });
|
|
81
|
+
// No gap table at all = legacy facts → skip. A table that MERELY LACKS the pair
|
|
82
|
+
// = the sub-part built empty (meshGaps skips empty meshes) → a declared gate
|
|
83
|
+
// must fail loudly, not skip, or verify.ok would vouch for an unverified pair.
|
|
84
|
+
const noReading = (base) => (facts.gaps
|
|
85
|
+
? { ...base, actual: null, status: "fail", pass: false,
|
|
86
|
+
message: "no measured distance for the pair",
|
|
87
|
+
hint: "one sub-part produced no mesh (an empty solid?) — fix the build before trusting this gate" }
|
|
88
|
+
: { ...base, actual: null, status: "skip", pass: null, message: "unavailable" });
|
|
89
|
+
|
|
90
|
+
if (contacts != null && !Array.isArray(contacts)) {
|
|
91
|
+
throw new Error(`contacts: must be an array of ["a", "b"] pairs, got ${JSON.stringify(contacts)}`);
|
|
92
|
+
}
|
|
93
|
+
for (const pair of contacts ?? []) {
|
|
94
|
+
if (!Array.isArray(pair) || pair.length !== 2) {
|
|
95
|
+
throw new Error(`contacts: each entry must be an ["a", "b"] pair, got ${JSON.stringify(pair)}`);
|
|
96
|
+
}
|
|
97
|
+
const [a, b] = pair;
|
|
98
|
+
const disabled = requirePair(a, b, "contacts");
|
|
99
|
+
declared.add(pairKey(a, b));
|
|
100
|
+
const base = { scope: "view", subpart: `${a}×${b}`, metric: "contact", kind: "gate", expr: "touching" };
|
|
101
|
+
if (disabled) { checks.push(disabledSkip(base)); continue; }
|
|
102
|
+
const g = gapFor(a, b);
|
|
103
|
+
if (!g) { checks.push(noReading(base)); continue; }
|
|
104
|
+
const overlapping = (facts.overlaps ?? []).some((o) => pairKey(o.a, o.b) === pairKey(a, b));
|
|
105
|
+
if (overlapping || g.distance <= CONTACT_EPS) {
|
|
106
|
+
checks.push({ ...base, actual: g.distance, status: "pass", pass: true, message: overlapping ? "in contact (overlapping)" : "in contact" });
|
|
107
|
+
} else {
|
|
108
|
+
checks.push({ ...base, actual: g.distance, status: "fail", pass: false,
|
|
109
|
+
message: `${g.distance.toFixed(3)}mm apart, expected touching`,
|
|
110
|
+
hint: PAIR_HINTS.contact, pattern: "near-miss-gap", location: g.at });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const [key, spec] of Object.entries(clearance ?? {})) {
|
|
115
|
+
const pair = key.split("×").map((s) => s.trim());
|
|
116
|
+
if (pair.length !== 2 || !pair[0] || !pair[1]) throw new Error(`clearance: pair key must be "a×b", got "${key}"`);
|
|
117
|
+
const [a, b] = pair;
|
|
118
|
+
const disabled = requirePair(a, b, "clearance");
|
|
119
|
+
declared.add(pairKey(a, b));
|
|
120
|
+
const { expr, hint: partHint } = normalizeExpectation(spec);
|
|
121
|
+
const base = { scope: "view", subpart: `${a}×${b}`, metric: "clearance", kind: "gate", expr: String(expr) };
|
|
122
|
+
if (disabled) { checks.push(disabledSkip(base)); continue; }
|
|
123
|
+
const g = gapFor(a, b);
|
|
124
|
+
if (!g) { checks.push(noReading(base)); continue; }
|
|
125
|
+
const { pass, message } = evaluateAssertion(parseAssertion(expr), g.distance);
|
|
126
|
+
const out = { ...base, actual: g.distance, status: pass ? "pass" : "fail", pass, message };
|
|
127
|
+
if (!pass) { out.hint = partHint ?? PAIR_HINTS.clearance; out.pattern = "near-miss-gap"; out.location = g.at; }
|
|
128
|
+
checks.push(out);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const g of facts.nearMisses ?? []) {
|
|
132
|
+
if (declared.has(pairKey(g.a, g.b))) continue;
|
|
133
|
+
checks.push({ scope: "view", subpart: `${g.a}×${g.b}`, metric: "nearMiss", kind: "warn",
|
|
134
|
+
expr: "intent undeclared", actual: g.distance, status: "warn", pass: false,
|
|
135
|
+
message: `${g.distance.toFixed(3)}mm gap`, hint: PAIR_HINTS.nearMiss,
|
|
136
|
+
pattern: "near-miss-gap", location: g.at });
|
|
137
|
+
}
|
|
138
|
+
return checks;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function check(scope, subpart, metric, spec, registry, factsObj) {
|
|
25
142
|
const reg = registry[metric];
|
|
26
143
|
if (!reg) throw new Error(`unknown ${scope} metric "${metric}"${subpart ? ` on sub-part "${subpart}"` : ""}`);
|
|
144
|
+
const { expr, hint: partHint } = normalizeExpectation(spec);
|
|
27
145
|
const actual = reg.extract(factsObj);
|
|
28
146
|
const base = { scope, subpart, metric, kind: reg.kind, expr: String(expr) };
|
|
29
147
|
if (actual === null || actual === undefined) {
|
|
30
148
|
if (reg.manifoldOnly) return { ...base, actual, status: "skip", pass: null, message: "n/a (OCCT backend)" };
|
|
31
|
-
if (metric === "minWall")
|
|
149
|
+
if (metric === "minWall") {
|
|
150
|
+
return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable",
|
|
151
|
+
hint: partHint ?? "no min-wall reading for this mesh — treat thin features as unverified" };
|
|
152
|
+
}
|
|
32
153
|
return { ...base, actual, status: "skip", pass: null, message: "unavailable" };
|
|
33
154
|
}
|
|
34
155
|
const { pass, message } = evaluateAssertion(parseAssertion(expr), actual);
|
|
35
156
|
const status = pass ? "pass" : reg.kind === "warn" ? "warn" : "fail";
|
|
36
|
-
|
|
157
|
+
const out = { ...base, actual, status, pass, message };
|
|
158
|
+
if (!pass) {
|
|
159
|
+
out.hint = partHint ?? reg.hint;
|
|
160
|
+
if (reg.pattern) out.pattern = reg.pattern;
|
|
161
|
+
const loc = reg.locate?.(factsObj);
|
|
162
|
+
if (loc) out.location = loc;
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
37
165
|
}
|
|
38
166
|
|
|
39
167
|
// Pure policy: profile rules + per-part expect → checks for one case's facts.
|
|
40
|
-
export function evaluateCase(facts, { profile, expect }) {
|
|
168
|
+
export function evaluateCase(facts, { profile, expect, subPartNames }) {
|
|
41
169
|
const checks = [];
|
|
170
|
+
// contacts/clearance are per-pair, not scalar view metrics — peel them off
|
|
171
|
+
// before the registry loop and hand them to pairGapChecks.
|
|
172
|
+
const { contacts, clearance, ...viewScalarExp } = expect?._view ?? {};
|
|
42
173
|
const viewExp = {
|
|
43
174
|
...(profile?.bed ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
|
|
44
|
-
...
|
|
175
|
+
...viewScalarExp,
|
|
45
176
|
};
|
|
46
177
|
for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
|
|
178
|
+
checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
|
|
47
179
|
|
|
48
180
|
for (const s of facts.subparts) {
|
|
49
181
|
const merged = {
|
|
@@ -59,11 +191,22 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
59
191
|
view = view ?? Object.keys(part.views)[0];
|
|
60
192
|
const profileSpec = process ?? part.verify?.process;
|
|
61
193
|
const profile = profileSpec ? resolveProfile(profileSpec) : null;
|
|
62
|
-
const
|
|
63
|
-
const expectMentionsMinWall = Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o);
|
|
64
|
-
const needMinWall = profile?.minWall != null || expectMentionsMinWall;
|
|
194
|
+
const expectSpec = part.verify?.expect ?? {};
|
|
65
195
|
|
|
66
196
|
const cases = expandCases(part);
|
|
197
|
+
// `expect` can be a pure function of the case's resolved params — (p, d) →
|
|
198
|
+
// expect object — so topology that legitimately changes with a preset (an
|
|
199
|
+
// optional drain or bore flipping the genus) can be pinned per case instead
|
|
200
|
+
// of one static number that some presets must violate.
|
|
201
|
+
const resolveExpect = (params) => {
|
|
202
|
+
if (typeof expectSpec !== "function") return expectSpec;
|
|
203
|
+
const { p, d } = resolveParams(part, params);
|
|
204
|
+
return expectSpec(p, d) ?? {};
|
|
205
|
+
};
|
|
206
|
+
const expanded = cases.map((c) => ({ ...c, expect: resolveExpect(c.params) }));
|
|
207
|
+
const expectMentionsMinWall = expanded.some(({ expect }) =>
|
|
208
|
+
Object.values(expect).some((o) => o && typeof o === "object" && "minWall" in o));
|
|
209
|
+
const needMinWall = profile?.minWall != null || expectMentionsMinWall;
|
|
67
210
|
const readKeys = subPartReadKeys(part, view, part.defaults);
|
|
68
211
|
const signature = (params) =>
|
|
69
212
|
readKeys === RELEVANT_ALL
|
|
@@ -77,7 +220,8 @@ export function verify(kernel, part, { process, view, measureFn = defaultMeasure
|
|
|
77
220
|
return memo.get(key);
|
|
78
221
|
};
|
|
79
222
|
|
|
80
|
-
const
|
|
223
|
+
const subPartNames = Object.keys(part.parts);
|
|
224
|
+
const caseResults = expanded.map(({ name, params, expect }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect, subPartNames }) }));
|
|
81
225
|
const all = caseResults.flatMap((c) => c.checks.map((ch) => ({ case: c.name, ...ch })));
|
|
82
226
|
return {
|
|
83
227
|
ok: !all.some((c) => c.status === "fail"),
|
package/src/testing.js
CHANGED
|
@@ -4,7 +4,10 @@
|
|
|
4
4
|
export { createManifoldKernel } from "./framework/geometry/manifold-backend.js";
|
|
5
5
|
export { bootManifoldKernel } from "./testing/manifold.js";
|
|
6
6
|
export { handle, viewSubParts } from "./framework/jobs.js";
|
|
7
|
+
export { resolveDerived } from "./framework/derive.js";
|
|
8
|
+
export { relevantParamKeys, RELEVANT_ALL } from "./framework/param-deps.js";
|
|
7
9
|
export { assemblyOverlaps } from "./framework/assembly.js";
|
|
10
|
+
export { assemblyGaps, meshGaps } from "./testing/gaps.js";
|
|
8
11
|
export { bootOcctKernel } from "./testing/occt.js";
|
|
9
12
|
export { meshVolume, bboxSize } from "./testing/mesh.js";
|
|
10
13
|
export { buildView } from "./testing/build.js";
|