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.
@@ -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, and whether a failure
8
- // is a hard gate or a warning. `manifoldOnly` facts are null on OCCT parts.
9
- const SUBPART_METRICS = {
10
- holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes },
11
- watertight: { kind: "gate", manifoldOnly: true, extract: (s) => s.watertight },
12
- volume: { kind: "gate", extract: (s) => s.volume },
13
- surfaceArea: { kind: "gate", extract: (s) => s.surfaceArea },
14
- triangleCount: { kind: "gate", extract: (s) => s.triangleCount },
15
- bbox: { kind: "gate", extract: (s) => s.bbox },
16
- minWall: { kind: "warn", extract: (s) => s.minWall },
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
- volume: { kind: "gate", extract: (r) => r.aggregate.volume },
21
- overlaps: { kind: "gate", extract: (r) => r.overlaps.length },
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
- function check(scope, subpart, metric, expr, registry, factsObj) {
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") return { ...base, actual, status: "warn", pass: null, message: "min wall unavailable" };
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
- return { ...base, actual, status, pass, message };
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
- ...(expect?._view ?? {}),
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 expect = part.verify?.expect ?? {};
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 caseResults = cases.map(({ name, params }) => ({ name, params, checks: evaluateCase(measureCase(params), { profile, expect }) }));
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";