partforge 0.161.0 → 0.162.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.
@@ -4375,12 +4375,16 @@ unverified.
4375
4375
 
4376
4376
  **Assertion DSL:** a bare number means equality (`holes: 1`); `">=n"`, `"<=n"`, `">n"`,
4377
4377
  `"<n"`, or a range `"a..b"`; an optional unit suffix `mm`/`cm`/`mm3`/`cm3`; and for
4378
- `bbox`, `centerOfMass` and each side of `bounds`, a componentwise vector `"<=[x,y,z]"` /
4379
- `">=[x,y,z]"` — exactly 3 components, each a plain number in mm or `*` to skip an axis.
4380
- A vector is always one-sided: a bare `[40,40,10]` (meaning "exactly this size") is
4381
- rejected, since a mesh's box shifts with tessellation and no tolerance would be honest
4382
- for every part — pick `<=` for a limit or `>=` for a floor. `bounds` takes an object
4383
- with `min` and/or `max`, each such a vector: `bounds: { min: ">=[0,0,0]", max:
4378
+ `bbox`, `centerOfMass` and each side of `bounds`, a componentwise vector — one-sided,
4379
+ `"<=[x,y,z]"` / `">=[x,y,z]"`, or two-sided with a range per axis,
4380
+ `"[28..32, 18..22, 9..11]"` (the bare comma form `"28..32,18..22,9..11"` is accepted
4381
+ too) — exactly 3 components, each a plain number in mm (or a range of them) or `*` to
4382
+ skip an axis. A bare `[40,40,10]` (meaning "exactly this size") is rejected, since a
4383
+ mesh's box shifts with tessellation and no tolerance would be honest for every part —
4384
+ pick `<=` for a limit, `>=` for a floor, or say what tolerance you mean with a range
4385
+ per axis (`"[39.5..40.5, 39.5..40.5, 9.8..10.2]"`; a zero-width `40..40` is an exact
4386
+ check with the parser's 1e-6 slack, and will fail on a chorded curve). `bounds` takes an
4387
+ object with `min` and/or `max`, each such a vector: `bounds: { min: ">=[0,0,0]", max:
4384
4388
  "<=[220,220,250]" }`. The parser is strict — a malformed assertion fails loudly.
4385
4389
 
4386
4390
  **A part whose typeface is a parameter needs band assertions, not points.** Glyph
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.161.0",
3
+ "version": "0.162.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",
@@ -160,6 +160,8 @@ function checkHeld(held, parts, path) {
160
160
  // `verify-bad-expr`, not `verify-bad-pair-check`: a malformed key is a pair-naming
161
161
  // problem, but a malformed value is an assertion-DSL problem, same as any other
162
162
  // metric's expectation.
163
+ 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]` or `[28..32,18..22,9..11]` (with `*` to skip an axis).";
164
+
163
165
  function checkClearanceExprs(clearance, path) {
164
166
  if (clearance === undefined || clearance === null || typeof clearance !== "object") return [];
165
167
  const out = [];
@@ -168,7 +170,7 @@ function checkClearanceExprs(clearance, path) {
168
170
  catch (e) {
169
171
  out.push(err("verify-bad-expr",
170
172
  `the expectation for _view.clearance["${key}"] is not a valid assertion: ${e?.message || String(e)}`,
171
- "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).",
173
+ DSL_HINT,
172
174
  `${path}[${JSON.stringify(key)}]`));
173
175
  }
174
176
  }
@@ -218,7 +220,7 @@ function checkProcessSpec(spec, path, valid, seen, depth = 0) {
218
220
  // One expectation's well-formedness against its registry entry: it parses, and it has the form the
219
221
  // metric reads — a range for `wall`, a componentwise vector for bbox/centerOfMass/the
220
222
  // sides of bounds. A bare vector gets the two legal spellings back, in its own numbers.
221
- 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).";
223
+ const VECTOR_OPS = new Set(["vle", "vge", "vrange"]);
222
224
  const capitalize = (t) => t.charAt(0).toUpperCase() + t.slice(1);
223
225
 
224
226
  function checkExpr(target, metric, spec, reg) {
@@ -246,10 +248,24 @@ function checkExpr(target, metric, spec, reg) {
246
248
  `Write ${metric} as \`"<min>..<max>"\` with min <= max, in mm.`,
247
249
  path)];
248
250
  }
249
- if (form === "vector" && parsed.op !== "vle" && parsed.op !== "vge") {
251
+ if (form === "vector" && parsed.op !== "vle" && parsed.op !== "vge" && parsed.op !== "vrange") {
250
252
  return [err("verify-bad-expr",
251
253
  `the expectation for ${target}.${metric} must be a componentwise vector, got ${describe(expr)} — ${metric} is an [x,y,z] reading`,
252
- `Write ${metric} as \`"<=[x,y,z]"\` or \`">=[x,y,z]"\` (componentwise; * skips an axis).`,
254
+ `Write ${metric} as \`"<=[x,y,z]"\`, \`">=[x,y,z]"\` or \`"[a..b,c..d,e..f]"\` (componentwise; * skips an axis).`,
255
+ path)];
256
+ }
257
+ // A vector on a scalar metric (`volume: "<=[1,2,3]"`) is not a parse error, and
258
+ // the evaluator would index a number and pass it vacuously — so it is caught here.
259
+ if (form !== "vector" && VECTOR_OPS.has(parsed.op)) {
260
+ return [err("verify-bad-expr",
261
+ `the expectation for ${target}.${metric} must be a single value, got ${describe(expr)} — ${metric} is one number, not an [x,y,z] reading`,
262
+ DSL_HINT,
263
+ path)];
264
+ }
265
+ if (parsed.op === "vrange" && parsed.vec.some((c) => c !== null && c[0] > c[1])) {
266
+ return [err("verify-bad-expr",
267
+ `the expectation for ${target}.${metric} must be a range vector with min <= max on every axis, got "${expr}"`,
268
+ `Write each axis of ${metric} as \`<min>..<max>\` with min <= max, in mm (or \`*\` to skip it).`,
253
269
  path)];
254
270
  }
255
271
  return [];
@@ -25,7 +25,8 @@ const reVec = /^(>=|<=)\s*\[\s*(.+?)\s*\]$/;
25
25
  // own numbers, which is a one-step fix. Returns that hint, or null when `expr` is
26
26
  // not a bare vector. An exact vector is deliberately not offered: a mesh's box is
27
27
  // tessellation-dependent (a curved face chords inside its true extent), so no
28
- // tolerance would be honest for every part.
28
+ // tolerance would be honest for every part — a writer who wants both sides
29
+ // declares the tolerance themselves with the per-axis range form below.
29
30
  const reBareVec = /^(?:==?)?\s*\[(.*)\]$/;
30
31
  const VEC_TAIL = "(componentwise; * skips an axis)";
31
32
 
@@ -36,6 +37,10 @@ export function bareVectorHint(expr) {
36
37
  const m = expr.trim().match(reBareVec);
37
38
  if (!m) return null;
38
39
  comps = m[1].split(",").map((t) => t.trim());
40
+ // A component with `..` in it is a range-vector attempt (below), not a bare
41
+ // vector: its own parse error names the right fix, and the two-spelling hint
42
+ // here would send the writer back to a one-sided form.
43
+ if (comps.some((c) => c.includes(".."))) return null;
39
44
  } else return null;
40
45
  comps = comps.map((c) => c.replace(/\s*mm$/, ""));
41
46
  const ok = comps.length === 3 && comps.every((c) => c === "*" || (c !== "" && Number.isFinite(Number(c))));
@@ -46,6 +51,33 @@ export function bareVectorHint(expr) {
46
51
 
47
52
  const show = (expr) => (typeof expr === "string" ? `"${expr}"` : JSON.stringify(expr));
48
53
 
54
+ // The two-sided vector form: a RANGE per axis, `"[28..32, 18..22, 9..11]"`, with `*`
55
+ // skipping an axis. The brackets are optional because the bare comma form
56
+ // (`"28..32,18..22,9..11"`) is what a model produces when it reaches for this —
57
+ // three trials wrote it before the form existed and were told "unrecognized form".
58
+ // Components are plain numbers in mm, like the one-sided vectors: a unit suffix
59
+ // is refused rather than scaled. Returns null when `s` is not a range-vector
60
+ // attempt at all (no `..` inside a bracket pair or comma list), so the other
61
+ // forms get their turn; a malformed attempt throws its own error.
62
+ const reRangeComp = new RegExp(`^(${NUM})\\s*\\.\\.\\s*(${NUM})$`);
63
+ const RANGE_VEC_SHAPE = "exactly 3 components, each a range a..b in mm or *";
64
+
65
+ function rangeVector(s) {
66
+ const bracketed = s.match(/^\[(.*)\]$/);
67
+ const inner = bracketed ? bracketed[1] : s;
68
+ if (!inner.includes("..") || !inner.includes(",")) return null;
69
+ if (!bracketed && /[\[\]]/.test(s)) return null; // one bracket of a pair — not this form
70
+ const parts = inner.split(",").map((t) => t.trim());
71
+ if (parts.length !== 3) throw new Error(`assertion: a range vector takes ${RANGE_VEC_SHAPE}: "${s}"`);
72
+ const vec = parts.map((t) => {
73
+ if (t === "*") return null;
74
+ const m = t.match(reRangeComp);
75
+ if (!m) throw new Error(`assertion: a range vector takes ${RANGE_VEC_SHAPE}: "${s}"`);
76
+ return [toBase(m[1], undefined), toBase(m[2], undefined)];
77
+ });
78
+ return { op: "vrange", vec };
79
+ }
80
+
49
81
  export function parseAssertion(expr) {
50
82
  if (typeof expr === "number" || typeof expr === "boolean") return { op: "eq", value: expr };
51
83
  const bare = bareVectorHint(expr);
@@ -53,6 +85,8 @@ export function parseAssertion(expr) {
53
85
  if (typeof expr !== "string") throw new Error(`assertion: unsupported value ${JSON.stringify(expr)}`);
54
86
  const s = expr.trim();
55
87
 
88
+ const vr = rangeVector(s);
89
+ if (vr) return vr;
56
90
  const vec = s.match(reVec);
57
91
  if (vec) {
58
92
  const parts = vec[2].split(",").map((t) => t.trim());
@@ -76,6 +110,7 @@ export function parseAssertion(expr) {
76
110
  const EPS = 1e-6;
77
111
  const approxEq = (a, b) => Math.abs(a - b) <= EPS + EPS * Math.abs(b);
78
112
  const fmtVec = (v) => "[" + v.map((x) => (x === null ? "*" : x)).join(",") + "]";
113
+ const fmtRangeVec = (v) => "[" + v.map((c) => (c === null ? "*" : `${c[0]}..${c[1]}`)).join(",") + "]";
79
114
 
80
115
  export function evaluateAssertion(parsed, actual) {
81
116
  switch (parsed.op) {
@@ -102,6 +137,15 @@ export function evaluateAssertion(parsed, actual) {
102
137
  }
103
138
  return { pass, message: `${fmtVec(actual)} ${ge ? ">=" : "<="} ${fmtVec(parsed.vec)}` };
104
139
  }
140
+ case "vrange": {
141
+ let pass = true;
142
+ for (let i = 0; i < 3; i++) {
143
+ const band = parsed.vec[i];
144
+ if (band === null) continue;
145
+ if (actual[i] < band[0] - EPS || actual[i] > band[1] + EPS) pass = false;
146
+ }
147
+ return { pass, message: `${fmtVec(actual)} ${pass ? "in" : "out of"} ${fmtRangeVec(parsed.vec)}` };
148
+ }
105
149
  default: throw new Error(`assertion: unknown op "${parsed.op}"`);
106
150
  }
107
151
  }
@@ -130,7 +130,7 @@ export const SUBPART_METRICS = {
130
130
  hint: "the rebuild's symmetric difference vs its reference import is too large — compare the ghost overlay, then adjust the governing dimensions toward the measured reference" },
131
131
  refVolumeDeltaPct: { kind: "gate", extract: (s) => s.deviation?.volumeDeltaPct ?? null,
132
132
  hint: "rebuild volume differs from the reference import by more than the allowed percentage — a feature is missing, doubled, or mis-scaled vs the reference" },
133
- refBboxDelta: { kind: "gate", extract: (s) => s.deviation?.bboxDelta ?? null,
133
+ refBboxDelta: { kind: "gate", form: "vector", extract: (s) => s.deviation?.bboxDelta ?? null,
134
134
  hint: "the rebuild's bounding-box corners drift from the reference import — check overall dimensions and that the rebuild is aligned to the reference's coordinates" },
135
135
  // Sheet parts (sheetPart(), partforge/geometry): each process's own checks. verify
136
136
  // volunteers them on every sheet sub-part; an author may declare them like any other.