partforge 0.154.0 → 0.155.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.
@@ -4267,11 +4267,11 @@ are skipped. Switch it off under an FDM profile with an inline
4267
4267
  `{ base: "fdm-pla", overhang: null }`. **What `expect` gives you:** per-sub-part
4268
4268
  assertions on the facts `measure` already reports — `holes` (through-bores / genus),
4269
4269
  `volume`, `surfaceArea`, `triangleCount`, `bbox`, `watertight`, `minWall`,
4270
- `overhangArea`, `wall` (a range — see below), `boundsMin` / `boundsMax`
4271
- (the axis-aligned `{min,max}` corner positions — where the geometry sits, vs
4272
- `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
4270
+ `overhangArea`, `wall` (a range — see below), `bounds` (the axis-aligned corner
4271
+ positions, written in the same `{ min, max }` shape `measure` reports them — where
4272
+ the geometry sits, vs `bbox` which is only its size) and `centerOfMass` (`[x,y,z]`, the volume-weighted
4273
4273
  centroid; `null` for a degenerate/zero-volume sub-part); and `_view` assertions `bbox`,
4274
- `volume`, `overlaps`, `centerOfMass`, `boundsMin`, `boundsMax`, plus the pair-wise
4274
+ `volume`, `overlaps`, `centerOfMass`, `bounds`, plus the pair-wise
4275
4275
  `contacts` / `clearance` below.
4276
4276
 
4277
4277
  Passing these checks does **not** prove structural strength, fatigue life, stability,
@@ -4283,9 +4283,13 @@ unverified.
4283
4283
 
4284
4284
  **Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
4285
4285
  `"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
4286
- `bbox`, `centerOfMass`, `boundsMin`, `boundsMax`, a componentwise vector `"<=[x,y,z]"` /
4287
- `">=[x,y,z]"` where `*` skips an axis. The parser is strict — a malformed assertion
4288
- fails loudly.
4286
+ `bbox`, `centerOfMass` and each side of `bounds`, a componentwise vector `"<=[x,y,z]"` /
4287
+ `">=[x,y,z]"` — exactly 3 components, each a plain number in mm or `*` to skip an axis.
4288
+ A vector is always one-sided: a bare `[40,40,10]` (meaning "exactly this size") is
4289
+ rejected, since a mesh's box shifts with tessellation and no tolerance would be honest
4290
+ for every part — pick `<=` for a limit or `>=` for a floor. `bounds` takes an object
4291
+ with `min` and/or `max`, each such a vector: `bounds: { min: ">=[0,0,0]", max:
4292
+ "<=[220,220,250]" }`. The parser is strict — a malformed assertion fails loudly.
4289
4293
 
4290
4294
  **A part whose typeface is a parameter needs band assertions, not points.** Glyph
4291
4295
  advance widths differ by family, so a `text2d` sub-part's `bbox`/`volume` shifts with
@@ -4297,8 +4301,8 @@ need this, but a part whose default already names a specific face does.
4297
4301
 
4298
4302
  ```js
4299
4303
  verify: { expect: {
4300
- stand: { boundsMin: ">=[0,0,0]", centerOfMass: "<=[*,*,25]" }, // sits in +octant, mass kept low
4301
- _view: { boundsMax: "<=[220,220,250]" }, // whole assembly fits the bed
4304
+ stand: { bounds: { min: ">=[0,0,0]" }, centerOfMass: "<=[*,*,25]" }, // sits in +octant, mass kept low
4305
+ _view: { bounds: { max: "<=[220,220,250]" } }, // whole assembly fits the bed
4302
4306
  } }
4303
4307
  ```
4304
4308
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.154.0",
3
+ "version": "0.155.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -3,9 +3,9 @@
3
3
  // which is also the documented reason CLI stdout isn't pure JSON in that case.
4
4
  // Catching them statically removes both the wasted boot and the stdout caveat.
5
5
  import { err } from "./finding.js";
6
- import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
6
+ import { SUBPART_METRICS, VIEW_METRICS, BOUNDS_FORM, teachableMetrics, sidesSpecError } from "../verify-metrics.js";
7
7
  import { PROFILES, ORIENTATIONS } from "../oracle/dfm-profiles.js";
8
- import { parseAssertion } from "../oracle/assert-dsl.js";
8
+ import { parseAssertion, bareVectorHint } from "../oracle/assert-dsl.js";
9
9
  import { suggest } from "../geometry/op-options.js";
10
10
  import { PROCESS_IDS } from "../process/registry.js";
11
11
  import { SHEET_DOC_ID } from "../sheet/constants.js";
@@ -187,6 +187,46 @@ function checkProcessSpec(spec, path, valid, seen, depth = 0) {
187
187
  path)];
188
188
  }
189
189
 
190
+ // One expectation's well-formedness against its registry entry: it parses, and it has the form the
191
+ // metric reads — a range for `wall`, a componentwise vector for bbox/centerOfMass/the
192
+ // sides of bounds. A bare vector gets the two legal spellings back, in its own numbers.
193
+ const DSL_HINT = "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).";
194
+ const capitalize = (t) => t.charAt(0).toUpperCase() + t.slice(1);
195
+
196
+ function checkExpr(target, metric, spec, reg) {
197
+ const path = `verify.expect.${target}.${metric}`;
198
+ const expr = exprOf(spec);
199
+ let parsed;
200
+ try { parsed = parseAssertion(expr); }
201
+ catch (e) {
202
+ const bare = bareVectorHint(expr);
203
+ return [err("verify-bad-expr",
204
+ `the expectation for ${target}.${metric} is not a valid assertion: ${e?.message || String(e)}`,
205
+ bare ? `${capitalize(bare)}. A vector expectation is one-sided: "<=" for a limit, ">=" for a floor.` : DSL_HINT,
206
+ path)];
207
+ }
208
+ const form = reg?.form;
209
+ if (form === "range" && parsed.op !== "range") {
210
+ return [err("verify-bad-expr",
211
+ `the expectation for ${target}.${metric} must be a range like "1.8..2.2" — the ${metric} metric needs both ends of its band`,
212
+ `Write ${metric} as \`"<min>..<max>"\` in mm.`,
213
+ path)];
214
+ }
215
+ if (form === "range" && parsed.min > parsed.max) {
216
+ return [err("verify-bad-expr",
217
+ `the expectation for ${target}.${metric} must be a range with min <= max, got "${expr}"`,
218
+ `Write ${metric} as \`"<min>..<max>"\` with min <= max, in mm.`,
219
+ path)];
220
+ }
221
+ if (form === "vector" && parsed.op !== "vle" && parsed.op !== "vge") {
222
+ return [err("verify-bad-expr",
223
+ `the expectation for ${target}.${metric} must be a componentwise vector, got ${describe(expr)} — ${metric} is an [x,y,z] reading`,
224
+ `Write ${metric} as \`"<=[x,y,z]"\` or \`">=[x,y,z]"\` (componentwise; * skips an axis).`,
225
+ path)];
226
+ }
227
+ return [];
228
+ }
229
+
190
230
  export const VERIFY_RULES = [
191
231
  {
192
232
  id: "verify-expect-throws",
@@ -229,9 +269,12 @@ export const VERIFY_RULES = [
229
269
  // separately by verify-bad-pair-check, and excluded here.
230
270
  const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
231
271
  const registry = target === "_view" ? VIEW_METRICS : SUBPART_METRICS;
232
- const valid = Object.keys(registry);
272
+ // Legacy spellings (boundsMin/boundsMax) still pass this rule, since verify
273
+ // still evaluates them, but are never listed or suggested: the vocabulary a
274
+ // reader is shown is the one the docs teach.
275
+ const valid = teachableMetrics(registry);
233
276
  for (const metric of Object.keys(metrics)) {
234
- if (valid.includes(metric)) continue;
277
+ if (Object.hasOwn(registry, metric)) continue;
235
278
  const hint = suggest(metric, valid);
236
279
  out.push(err("verify-unknown-metric",
237
280
  `"${metric}" is not a ${target === "_view" ? "view" : "sub-part"} metric`,
@@ -256,28 +299,28 @@ export const VERIFY_RULES = [
256
299
  // though, so they get their own pass (with a pair-shaped path) below.
257
300
  const metrics = target === "_view" ? peelPairKeys(metricsRaw) : metricsRaw;
258
301
  const registry = target === "_view" ? VIEW_METRICS : SUBPART_METRICS;
259
- for (const [metric, spec] of Object.entries(metrics)) {
260
- let parsed;
261
- try { parsed = parseAssertion(exprOf(spec)); }
262
- catch (e) {
263
- out.push(err("verify-bad-expr",
264
- `the expectation for ${target}.${metric} is not a valid assertion: ${e?.message || String(e)}`,
265
- "Use the assertion DSL: a bare value for equality, a comparison like `>=3`, a range like `2..5`, or a componentwise vector like `<=[60,60,60]` (with `*` to skip an axis).",
266
- `verify.expect.${target}.${metric}`));
302
+ for (const [metric, specRaw] of Object.entries(metrics)) {
303
+ // A compound metric (`bounds: { min, max }`) is checked side by side, each
304
+ // side an ordinary expectation against its own registry entry.
305
+ // An unknown metric is verify-unknown-metric's finding; parsing its value
306
+ // too would only add a second, vaguer error for the same key.
307
+ if (!Object.hasOwn(registry, metric)) continue;
308
+ const reg = registry[metric];
309
+ if (reg.sides) {
310
+ const bad = sidesSpecError(metric, reg, specRaw);
311
+ if (bad) {
312
+ out.push(err("verify-bad-expr",
313
+ `the expectation for ${target}.${metric} is malformed: ${bad}`,
314
+ `Write \`${BOUNDS_FORM}\` — the same { min, max } shape measure reports. Either side may be left out, and \`*\` skips an axis.`,
315
+ `verify.expect.${target}.${metric}`));
316
+ continue;
317
+ }
318
+ for (const [side, sideSpec] of Object.entries(specRaw)) {
319
+ out.push(...checkExpr(target, `${metric}.${side}`, sideSpec, reg.sides[side]));
320
+ }
267
321
  continue;
268
322
  }
269
- const form = registry[metric]?.form;
270
- if (form === "range" && parsed.op !== "range") {
271
- out.push(err("verify-bad-expr",
272
- `the expectation for ${target}.${metric} must be a range like "1.8..2.2" — the ${metric} metric needs both ends of its band`,
273
- `Write ${metric} as \`"<min>..<max>"\` in mm.`,
274
- `verify.expect.${target}.${metric}`));
275
- } else if (form === "range" && parsed.op === "range" && parsed.min > parsed.max) {
276
- out.push(err("verify-bad-expr",
277
- `the expectation for ${target}.${metric} must be a range with min <= max, got "${exprOf(spec)}"`,
278
- `Write ${metric} as \`"<min>..<max>"\` with min <= max, in mm.`,
279
- `verify.expect.${target}.${metric}`));
280
- }
323
+ out.push(...checkExpr(target, metric, specRaw, reg));
281
324
  }
282
325
  }
283
326
  out.push(...checkClearanceExprs(expect?._view?.clearance, "verify.expect._view.clearance"));
@@ -19,15 +19,47 @@ const reScalar = new RegExp(`^(>=|<=|>|<)?\\s*(${NUM})\\s*${U}$`);
19
19
  const reRange = new RegExp(`^(${NUM})\\s*\\.\\.\\s*(${NUM})\\s*${U}$`);
20
20
  const reVec = /^(>=|<=)\s*\[\s*(.+?)\s*\]$/;
21
21
 
22
+ // A vector with no comparison — `[40,40,10]` or `"[40,40,10]"` (or `"=[…]"`) — is the
23
+ // commonest slip: the writer means "exactly this size". A vector is only ever
24
+ // one-sided, so the error hands back both legal spellings built from the writer's
25
+ // own numbers, which is a one-step fix. Returns that hint, or null when `expr` is
26
+ // not a bare vector. An exact vector is deliberately not offered: a mesh's box is
27
+ // tessellation-dependent (a curved face chords inside its true extent), so no
28
+ // tolerance would be honest for every part.
29
+ const reBareVec = /^(?:==?)?\s*\[(.*)\]$/;
30
+ const VEC_TAIL = "(componentwise; * skips an axis)";
31
+
32
+ export function bareVectorHint(expr) {
33
+ let comps;
34
+ if (Array.isArray(expr)) comps = expr.map((c) => String(c).trim());
35
+ else if (typeof expr === "string") {
36
+ const m = expr.trim().match(reBareVec);
37
+ if (!m) return null;
38
+ comps = m[1].split(",").map((t) => t.trim());
39
+ } else return null;
40
+ comps = comps.map((c) => c.replace(/\s*mm$/, ""));
41
+ const ok = comps.length === 3 && comps.every((c) => c === "*" || (c !== "" && Number.isFinite(Number(c))));
42
+ if (!ok) return `use "<=[x,y,z]" or ">=[x,y,z]" (exactly 3 plain numbers in mm; componentwise; * skips an axis)`;
43
+ const v = `[${comps.join(",")}]`;
44
+ return `use "<=${v}" or ">=${v}" ${VEC_TAIL}`;
45
+ }
46
+
47
+ const show = (expr) => (typeof expr === "string" ? `"${expr}"` : JSON.stringify(expr));
48
+
22
49
  export function parseAssertion(expr) {
23
50
  if (typeof expr === "number" || typeof expr === "boolean") return { op: "eq", value: expr };
51
+ const bare = bareVectorHint(expr);
52
+ if (bare) throw new Error(`assertion: a vector needs a comparison, got ${show(expr)} — ${bare}`);
24
53
  if (typeof expr !== "string") throw new Error(`assertion: unsupported value ${JSON.stringify(expr)}`);
25
54
  const s = expr.trim();
26
55
 
27
56
  const vec = s.match(reVec);
28
57
  if (vec) {
29
58
  const parts = vec[2].split(",").map((t) => t.trim());
30
- if (parts.length !== 3) throw new Error(`assertion: vector needs 3 components: "${expr}"`);
59
+ const plain = (t) => t === "*" || (t !== "" && Number.isFinite(Number(t)));
60
+ if (parts.length !== 3 || !parts.every(plain)) {
61
+ throw new Error(`assertion: a vector takes exactly 3 components, each a plain number in mm or *: "${expr}"`);
62
+ }
31
63
  return { op: vec[1] === "<=" ? "vle" : "vge", vec: parts.map((t) => (t === "*" ? null : toBase(t, undefined))) };
32
64
  }
33
65
  const range = s.match(reRange);
@@ -4,7 +4,7 @@ import { pairKey, CONTACT_EPS } from "./gaps.js";
4
4
  import { resolveProfile, overhangAngleFor } from "./dfm-profiles.js";
5
5
  import { expandExpectations, partGatesMinWall } from "./gates.js";
6
6
  import { relevanceHash } from "../param-deps.js";
7
- import { SUBPART_METRICS, VIEW_METRICS } from "../verify-metrics.js";
7
+ import { SUBPART_METRICS, VIEW_METRICS, sidesSpecError } from "../verify-metrics.js";
8
8
  import { processById } from "../process/registry.js";
9
9
  import { SHEET_DOC_ID, SHEET_CHECKS_NOTICE, SHEET_READ_ERROR_HINT } from "../sheet/constants.js";
10
10
  // Re-exported for the callers that read them from here; their one home is sheet/constants.js.
@@ -173,6 +173,21 @@ function check(scope, subpart, metric, spec, registry, factsObj) {
173
173
  return out;
174
174
  }
175
175
 
176
+ // Every check one declared metric yields: one, or one per side of a compound metric
177
+ // (`bounds: { min, max }` → `bounds.min`, `bounds.max`), each judged by `check()` against
178
+ // its side's own registry entry. A malformed compound value throws, as a malformed
179
+ // expression does.
180
+ function checksFor(scope, subpart, metric, spec, registry, factsObj) {
181
+ const reg = registry[metric];
182
+ if (!reg?.sides) return [check(scope, subpart, metric, spec, registry, factsObj)];
183
+ const bad = sidesSpecError(metric, reg, spec);
184
+ if (bad) throw new Error(`${scope === "view" ? "_view" : subpart}: ${bad}`);
185
+ return Object.entries(spec).map(([side, sideSpec]) => {
186
+ const name = `${metric}.${side}`;
187
+ return check(scope, subpart, name, sideSpec, { [name]: reg.sides[side] }, factsObj);
188
+ });
189
+ }
190
+
176
191
  // ── sheet parts ──────────────────────────────────────────────────────────────
177
192
  // A view holding a sheet part (sheetPart(), partforge/geometry — measure() stamps
178
193
  // its row with `sheet`) is judged per part: the process profile's bed fits each
@@ -220,8 +235,7 @@ function sheetRowChecks(s, declaredExp) {
220
235
  const budgeted = (metric) => SUBPART_METRICS[metric]?.budgeted === true;
221
236
  const unread = (c, metric) => (c.actual == null ? SUBPART_METRICS[metric]?.readError?.(s) ?? null : null);
222
237
  const readFailure = (c, why) => ({ ...c, message: `not measured — ${why}`, hint: SHEET_READ_ERROR_HINT, pattern: SHEET_DOC_ID });
223
- for (const [metric, expr] of Object.entries(declaredExp)) {
224
- const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
238
+ for (const [metric, expr] of Object.entries(declaredExp)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, s)) {
225
239
  if (PRINT_ONLY.has(metric)) {
226
240
  out.push({ scope: c.scope, subpart: c.subpart, metric, kind: c.kind, expr: c.expr, actual: null, status: "skip", pass: null,
227
241
  message: "not measured on a sheet part", hint: PRINT_ONLY_HINT, pattern: SHEET_DOC_ID });
@@ -269,7 +283,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
269
283
  ...(profile?.bed && !anySheet ? { bbox: `<=[${profile.bed.join(",")}]` } : {}),
270
284
  ...viewScalarExp,
271
285
  };
272
- for (const [metric, expr] of Object.entries(viewExp)) checks.push(check("view", null, metric, expr, VIEW_METRICS, facts));
286
+ for (const [metric, expr] of Object.entries(viewExp)) checks.push(...checksFor("view", null, metric, expr, VIEW_METRICS, facts));
273
287
  checks.push(...pairGapChecks(facts, { contacts, clearance }, subPartNames));
274
288
 
275
289
  // Same rule as gapsSkipped above, one metric over: a min-wall gate with no reading
@@ -297,8 +311,7 @@ export function evaluateCase(facts, { profile, expect, subPartNames, overhang =
297
311
  ...(overhang != null ? { overhangArea: "<=1" } : {}),
298
312
  ...(expect?.[s.name] ?? {}),
299
313
  };
300
- for (const [metric, expr] of Object.entries(merged)) {
301
- const c = check("subpart", s.name, metric, expr, SUBPART_METRICS, s);
314
+ for (const [metric, expr] of Object.entries(merged)) for (const c of checksFor("subpart", s.name, metric, expr, SUBPART_METRICS, s)) {
302
315
  if (minWallSkipped && (metric === "minWall" || metric === "wall") && c.actual == null) {
303
316
  checks.push({ ...c, unevaluated: true, message: "not measured (quick check)",
304
317
  hint: `re-run this check without \`quick\` to measure ${metric === "wall" ? "the wall band" : "min wall"}` });
@@ -15,6 +15,54 @@
15
15
  // sheet/constants.js, lint/finding.js) — so the linter's stays kernel-free.
16
16
  import { SHEET_METRICS } from "./process/registry.js";
17
17
 
18
+ // `bounds` is written the way measure() reports it — `bounds: { min: [x,y,z], max:
19
+ // [x,y,z] }` — so a reader who copies the shape of a measurement gets a legal
20
+ // expectation: `bounds: { min: ">=[0,0,0]", max: "<=[220,220,250]" }`. It is the one
21
+ // COMPOUND metric: its value is an object of `sides`, each an ordinary vector
22
+ // expectation checked on its own and reported as `bounds.min` / `bounds.max`. Either
23
+ // side may be left out; nothing else may sit under it (boundsSpecError).
24
+ export const BOUNDS_FORM = 'bounds: { min: ">=[x,y,z]", max: "<=[x,y,z]" }';
25
+ const BOUNDS_HINT = `write ${BOUNDS_FORM} — either side may be left out, and * skips an axis`;
26
+
27
+ function boundsMetric(bounds, minHint, maxHint) {
28
+ return { kind: "gate", hint: BOUNDS_HINT, sides: {
29
+ min: { kind: "gate", form: "vector", extract: (f) => bounds(f)?.min, hint: minHint },
30
+ max: { kind: "gate", form: "vector", extract: (f) => bounds(f)?.max, hint: maxHint },
31
+ } };
32
+ }
33
+
34
+ // The flat spellings `bounds` replaced. Still evaluated, so a part written against
35
+ // them keeps verifying, but `legacy` keeps them out of everything that TEACHES the
36
+ // vocabulary: lint's list of valid metrics and its did-you-mean (teachableMetrics).
37
+ function legacyBounds(bounds, minHint, maxHint) {
38
+ return {
39
+ boundsMin: { kind: "gate", form: "vector", legacy: "bounds", extract: (f) => bounds(f)?.min, hint: minHint },
40
+ boundsMax: { kind: "gate", form: "vector", legacy: "bounds", extract: (f) => bounds(f)?.max, hint: maxHint },
41
+ };
42
+ }
43
+
44
+ // The metric names a reader should be shown: every registry key but the legacy ones.
45
+ export const teachableMetrics = (registry) => Object.keys(registry).filter((k) => !registry[k].legacy);
46
+
47
+ // Why a compound metric's value is malformed, or null when it is well formed. verify
48
+ // throws with it and lint reports it, so both say the same thing.
49
+ export function sidesSpecError(metric, reg, spec) {
50
+ const sides = Object.keys(reg.sides);
51
+ const form = metric === "bounds" ? BOUNDS_FORM : `${metric}: { ${sides.join(", ")} }`;
52
+ if (spec === null || typeof spec !== "object" || Array.isArray(spec)) {
53
+ let shown;
54
+ try { shown = JSON.stringify(spec); } catch { shown = String(spec); }
55
+ return `${metric} takes an object { ${sides.join(", ")} }, got ${shown} — write ${form}`;
56
+ }
57
+ const keys = Object.keys(spec);
58
+ const unknown = keys.filter((k) => !sides.includes(k));
59
+ if (unknown.length) {
60
+ return `${metric} accepts only ${sides.map((k) => `"${k}"`).join(" and ")}, got ${unknown.map((k) => `"${k}"`).join(", ")} — write ${form}`;
61
+ }
62
+ if (!keys.length) return `${metric} needs at least one of ${sides.join(" or ")} — write ${form}`;
63
+ return null;
64
+ }
65
+
18
66
  export const SUBPART_METRICS = {
19
67
  holes: { kind: "gate", manifoldOnly: true, extract: (s) => s.holes,
20
68
  hint: "genus is wrong — an unintended tunnel exists or an intended bore is blocked; make cut tools pierce fully (overcut past the faces)" },
@@ -27,14 +75,16 @@ export const SUBPART_METRICS = {
27
75
  hint: "surface area is out of range — detail features (facets, ribs, textures) are missing or doubled" },
28
76
  triangleCount: { kind: "gate", extract: (s) => s.triangleCount,
29
77
  hint: "triangle count is out of range — tessellation quality or feature count changed unexpectedly" },
30
- bbox: { kind: "gate", extract: (s) => s.bbox,
78
+ bbox: { kind: "gate", form: "vector", extract: (s) => s.bbox,
31
79
  hint: "bounding box is out of range — check the governing dimensions and the part's orientation" },
32
- centerOfMass: { kind: "gate", extract: (s) => s.centerOfMass,
80
+ centerOfMass: { kind: "gate", form: "vector", extract: (s) => s.centerOfMass,
33
81
  hint: "center of mass is outside the expected region — mass is distributed differently than intended; check feature placement or a mis-scaled sub-part" },
34
- boundsMin: { kind: "gate", extract: (s) => s.bounds?.min,
35
- hint: "the low corner is out of range — the part is positioned or oriented differently than expected" },
36
- boundsMax: { kind: "gate", extract: (s) => s.bounds?.max,
37
- hint: "the high corner is out of range — the part is positioned or oriented differently than expected" },
82
+ bounds: boundsMetric((s) => s.bounds,
83
+ "the low corner is out of range — the part is positioned or oriented differently than expected",
84
+ "the high corner is out of range — the part is positioned or oriented differently than expected"),
85
+ ...legacyBounds((s) => s.bounds,
86
+ "the low corner is out of range — the part is positioned or oriented differently than expected",
87
+ "the high corner is out of range — the part is positioned or oriented differently than expected"),
38
88
  minWall: { kind: "warn", extract: (s) => s.minWall,
39
89
  hint: "thinnest wall is at the reported location — increase the governing wall/thickness parameter or reduce the intersecting feature's depth",
40
90
  pattern: "minwall-sliver-triangles",
@@ -87,17 +137,19 @@ export const SUBPART_METRICS = {
87
137
  ...SHEET_METRICS,
88
138
  };
89
139
  export const VIEW_METRICS = {
90
- bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
140
+ bbox: { kind: "gate", form: "vector", extract: (r) => r.aggregate.bbox,
91
141
  hint: "the assembled view exceeds its size limit — shrink the assembly or pick a process with a larger bed" },
92
142
  volume: { kind: "gate", extract: (r) => r.aggregate.volume,
93
143
  hint: "total assembly volume is out of range — a sub-part is missing, doubled, or mis-scaled" },
94
144
  overlaps: { kind: "gate", extract: (r) => r.overlaps.length,
95
145
  hint: "sub-parts interpenetrate near the reported location — adjust placement or add clearance in derive()",
96
146
  locate: (r) => r.overlaps[0]?.location ?? null },
97
- centerOfMass: { kind: "gate", extract: (r) => r.aggregate.centerOfMass,
147
+ centerOfMass: { kind: "gate", form: "vector", extract: (r) => r.aggregate.centerOfMass,
98
148
  hint: "the assembly's center of mass is outside the expected region — a sub-part is mis-placed or mis-scaled" },
99
- boundsMin: { kind: "gate", extract: (r) => r.aggregate.bounds?.min,
100
- hint: "the assembly's low corner is out of range — check placement or orientation" },
101
- boundsMax: { kind: "gate", extract: (r) => r.aggregate.bounds?.max,
102
- hint: "the assembly's high corner is out of range — check placement or orientation" },
149
+ bounds: boundsMetric((r) => r.aggregate.bounds,
150
+ "the assembly's low corner is out of range — check placement or orientation",
151
+ "the assembly's high corner is out of range — check placement or orientation"),
152
+ ...legacyBounds((r) => r.aggregate.bounds,
153
+ "the assembly's low corner is out of range — check placement or orientation",
154
+ "the assembly's high corner is out of range — check placement or orientation"),
103
155
  };
package/types/part.d.ts CHANGED
@@ -506,6 +506,16 @@ export type AssertionExpr = number | string | boolean;
506
506
  /** An expectation, optionally carrying a part-authored corrective `hint`. */
507
507
  export type Expectation = AssertionExpr | { expr: AssertionExpr; hint?: string };
508
508
 
509
+ /**
510
+ * `bounds`: where the geometry sits, written in the same `{ min, max }` shape
511
+ * `measure` reports — `{ min: ">=[0,0,0]", max: "<=[220,220,250]" }`. Each side is
512
+ * a componentwise vector expectation (`*` skips an axis); either may be left out.
513
+ */
514
+ export interface BoundsExpectation {
515
+ min?: Expectation;
516
+ max?: Expectation;
517
+ }
518
+
509
519
  /**
510
520
  * Per-sub-part expectations. The keys are exactly `SUBPART_METRICS` in
511
521
  * src/framework/verify-metrics.js. `holes`/`watertight` are Manifold-only and
@@ -519,7 +529,11 @@ export interface SubPartExpectations {
519
529
  triangleCount?: Expectation;
520
530
  bbox?: Expectation;
521
531
  centerOfMass?: Expectation;
532
+ /** The axis-aligned corners: `{ min: ">=[x,y,z]", max: "<=[x,y,z]" }`, as `measure` reports `bounds`. */
533
+ bounds?: BoundsExpectation;
534
+ /** @deprecated Write `bounds: { min }` — still evaluated, no longer taught. */
522
535
  boundsMin?: Expectation;
536
+ /** @deprecated Write `bounds: { max }` — still evaluated, no longer taught. */
523
537
  boundsMax?: Expectation;
524
538
  minWall?: Expectation;
525
539
  /**
@@ -574,7 +588,11 @@ export interface ViewExpectations {
574
588
  volume?: Expectation;
575
589
  overlaps?: Expectation;
576
590
  centerOfMass?: Expectation;
591
+ /** The axis-aligned corners: `{ min: ">=[x,y,z]", max: "<=[x,y,z]" }`, as `measure` reports `bounds`. */
592
+ bounds?: BoundsExpectation;
593
+ /** @deprecated Write `bounds: { min }` — still evaluated, no longer taught. */
577
594
  boundsMin?: Expectation;
595
+ /** @deprecated Write `bounds: { max }` — still evaluated, no longer taught. */
578
596
  boundsMax?: Expectation;
579
597
  /** Pairs that must touch, as `[["a", "b"], …]`. */
580
598
  contacts?: Array<[string, string]>;