brepjs-cad 0.29.0 → 0.30.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/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.0](https://github.com/andymai/brepjs/compare/brepjs-cad-v0.29.0...brepjs-cad-v0.30.0) (2026-06-22)
4
+
5
+
6
+ ### Features
7
+
8
+ * **brepjs-cad:** add airfoils.md feature recipe (swept fans/props/impellers) ([#1566](https://github.com/andymai/brepjs/issues/1566)) ([8c786db](https://github.com/andymai/brepjs/commit/8c786db69ead59edcdc9d0f8525e43324552e4db))
9
+ * **brepjs-cad:** aimed section cut for the design judge (Phase 3) ([#1543](https://github.com/andymai/brepjs/issues/1543)) ([40e5acd](https://github.com/andymai/brepjs/commit/40e5acd54318d768dc5a620403d17d43e358e698))
10
+ * **brepjs-cad:** decomposed, cross-checked design-judge rubric (Phase 1.5) ([#1539](https://github.com/andymai/brepjs/issues/1539)) ([8059b6d](https://github.com/andymai/brepjs/commit/8059b6d701ae5980e6bb11f51695aa102fe2bb43))
11
+ * **brepjs-cad:** deterministic body/interference metrics for the design judge (Phase 1) ([#1531](https://github.com/andymai/brepjs/issues/1531)) ([d296b54](https://github.com/andymai/brepjs/commit/d296b545f2d7084b861683d61b626ace4747a371))
12
+ * **brepjs-cad:** kernel-anchored Set-of-Marks for the design judge ([#1545](https://github.com/andymai/brepjs/issues/1545)) ([2b6f5b4](https://github.com/andymai/brepjs/commit/2b6f5b436f2a73c96747cc71f83afc8715765cd0))
13
+ * **brepjs-cad:** reliable internal-bore detection for the design judge (keystone) ([#1542](https://github.com/andymai/brepjs/issues/1542)) ([0d780c3](https://github.com/andymai/brepjs/commit/0d780c3b73c2551dab2f04a6ff31bf9ec8cae2ab))
14
+ * **brepjs-cad:** surface fragmentation in verify --check (the [#1](https://github.com/andymai/brepjs/issues/1) design defect) ([#1560](https://github.com/andymai/brepjs/issues/1560)) ([45a4f6e](https://github.com/andymai/brepjs/commit/45a4f6e268331b7816cd9957fbdab4c0893b2000))
15
+ * **brepjs-cad:** xray internal-reveal shot for the design judge (Phase 2a) ([#1540](https://github.com/andymai/brepjs/issues/1540)) ([eca5bc9](https://github.com/andymai/brepjs/commit/eca5bc9ad20fc7947132d647952245cfe466a7f4))
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * **brepjs-cad:** add a triangular-gusset recipe to the implement skill (bracket/enclosure eval) ([#1571](https://github.com/andymai/brepjs/issues/1571)) ([346d042](https://github.com/andymai/brepjs/commit/346d0423fefa3bd163f4075f69bf96d478543bf5))
21
+ * **brepjs-cad:** add the "realize the designed object" bar to the implement skill ([#1563](https://github.com/andymai/brepjs/issues/1563)) ([e150357](https://github.com/andymai/brepjs/commit/e1503570aaf271ebef1e39d48be9c70a5ffe8f03))
22
+ * **brepjs-cad:** close gear-teeth edge cases found by /eval-skill (ring, worm tip relief, GT2) ([#1548](https://github.com/andymai/brepjs/issues/1548)) ([4bd0494](https://github.com/andymai/brepjs/commit/4bd049401f3affcc3f38e3a31bb9520653a3e89c))
23
+ * **brepjs-cad:** code + hint the degenerate-edge (duplicate-vertex) verify failure ([#1550](https://github.com/andymai/brepjs/issues/1550)) ([502dffc](https://github.com/andymai/brepjs/commit/502dffc4a2ae495ae5c98b0c7966721abe0d5f48))
24
+ * **brepjs-cad:** correct spur-gear example flanks + add root fillet ([#1528](https://github.com/andymai/brepjs/issues/1528)) ([6971c6b](https://github.com/andymai/brepjs/commit/6971c6befa1fea36a8c2f3ee89c4aa20946020c7))
25
+ * **brepjs-cad:** exploded-joint legibility + disjoint-compound caveat (basics eval) ([#1565](https://github.com/andymai/brepjs/issues/1565)) ([9d06121](https://github.com/andymai/brepjs/commit/9d0612111a595e57fcfca6fd191ed9169fc2c062))
26
+ * **brepjs-cad:** handle brepjs/playground refs + summarize body relations in the digest ([#1547](https://github.com/andymai/brepjs/issues/1547)) ([b0acef0](https://github.com/andymai/brepjs/commit/b0acef0ad80cdd61f0d2683f1e6d0ece0cae028e))
27
+ * **brepjs-cad:** heal 6 implement-skill findings from the full-flywheel re-run ([#1568](https://github.com/andymai/brepjs/issues/1568)) ([f63bf3d](https://github.com/andymai/brepjs/commit/f63bf3d5a0aa638d06e11dd7e2c27e84dbf74746))
28
+ * **brepjs-cad:** heal implement + polish skills from clean-room eval ([#1526](https://github.com/andymai/brepjs/issues/1526)) ([0645e16](https://github.com/andymai/brepjs/commit/0645e163e1d8759c95406d0f9526d0aa3b5dd480))
29
+ * **brepjs-cad:** heal implement skill — deep-stack bounds extremes aren't datums ([#1529](https://github.com/andymai/brepjs/issues/1529)) ([5ed366f](https://github.com/andymai/brepjs/commit/5ed366f266b3e6e50ae4b54d5779aa05453e894a))
30
+ * **brepjs-cad:** heal implement skill from the eval flywheel (3 findings) ([#1552](https://github.com/andymai/brepjs/issues/1552)) ([51def82](https://github.com/andymai/brepjs/commit/51def82db4e0600e85f45e4dd828cc9caee58d15))
31
+ * **brepjs-cad:** heal implement skill from the full-corpus eval flywheel (4 findings) ([#1554](https://github.com/andymai/brepjs/issues/1554)) ([3cfc28e](https://github.com/andymai/brepjs/commit/3cfc28ed8b82c46330f1d75f1e98c13fd4d65a06))
32
+ * **brepjs-cad:** make blind-judge reference adaptation render multi-body parts at scale ([#1530](https://github.com/andymai/brepjs/issues/1530)) ([18d4523](https://github.com/andymai/brepjs/commit/18d4523c85e1b22496bb5c0b8fb382a1146429c6))
33
+ * **brepjs-cad:** polish skill — bosses on shells must fuse, or they float (eval finding) ([#1553](https://github.com/andymai/brepjs/issues/1553)) ([07cbf93](https://github.com/andymai/brepjs/commit/07cbf93e47fbf880df53bc2a1cee05bf0d17e2d5))
34
+ * **brepjs-cad:** teach worm-wheel and rack tooth recipes; fix gear-build contradiction ([#1544](https://github.com/andymai/brepjs/issues/1544)) ([b0da85f](https://github.com/andymai/brepjs/commit/b0da85f218d463e0f1ef0ca8760cec54b4814b02))
35
+
3
36
  ## [0.29.0](https://github.com/andymai/brepjs/compare/brepjs-cad-v0.28.0...brepjs-cad-v0.29.0) (2026-06-20)
4
37
 
5
38
 
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_diff = require("./diff-kQJXMzLR.cjs");
2
+ const require_diff = require("./diff-DhOTZnG_.cjs");
3
3
  exports.DEFAULT_TOLERANCE_PCT = require_diff.DEFAULT_TOLERANCE_PCT;
4
4
  exports.TYPECHECK_CODE = require_diff.TYPECHECK_CODE;
5
5
  exports.emptyReport = require_diff.emptyReport;
@@ -1,2 +1,2 @@
1
- import { a as typecheckPart, c as isExpectedDims, d as emptyReport, i as TYPECHECK_CODE, l as pctDelta, m as serializeReport, n as runMeasure, o as DEFAULT_TOLERANCE_PCT, r as runPart, s as evaluateExpected, t as runDiff, u as runChecks } from "./diff-DcKUadBa.js";
1
+ import { a as typecheckPart, c as isExpectedDims, d as emptyReport, i as TYPECHECK_CODE, l as pctDelta, m as serializeReport, n as runMeasure, o as DEFAULT_TOLERANCE_PCT, r as runPart, s as evaluateExpected, t as runDiff, u as runChecks } from "./diff-MBAc87Xi.js";
2
2
  export { DEFAULT_TOLERANCE_PCT, TYPECHECK_CODE, emptyReport, evaluateExpected, isExpectedDims, pctDelta, runChecks, runDiff, runMeasure, runPart, serializeReport, typecheckPart };
package/dist/cli/main.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
3
- const require_diff = require("../diff-kQJXMzLR.cjs");
3
+ const require_diff = require("../diff-DhOTZnG_.cjs");
4
4
  let node_url = require("node:url");
5
5
  let node_fs = require("node:fs");
6
6
  let node_path = require("node:path");
@@ -215,6 +215,67 @@ function openBrowser(url, platform = process.platform) {
215
215
  } catch {}
216
216
  }
217
217
  //#endregion
218
+ //#region src/snapshot/aiming.ts
219
+ /**
220
+ * Kernel-anchored marks for the judge: `B<index>` at each body's bbox centroid (only for multi-body
221
+ * parts — a single body needs no label) and `H1..` at each bore's axis. The same id lands on the same
222
+ * feature across every view (it's a 3D anchor projected per-view), giving the judge view-invariant,
223
+ * part-space addressing without per-body mesh color.
224
+ *
225
+ * Body labels use the body's INDEX (0-based), matching how the measured-facts digest refers to bodies
226
+ * ("bodies 0&1: interfering"), so the judge can cross-reference a mark with the facts. A body that
227
+ * can't be located (no bounds) simply gets no mark — there is no resequenced gap.
228
+ */
229
+ function featureMarks(bodies, bores) {
230
+ const marks = [];
231
+ if (bodies.length > 1) for (const b of bodies) {
232
+ if (!b.bounds) continue;
233
+ marks.push({
234
+ label: `B${b.index}`,
235
+ pos: [
236
+ (b.bounds.xMin + b.bounds.xMax) / 2,
237
+ (b.bounds.yMin + b.bounds.yMax) / 2,
238
+ (b.bounds.zMin + b.bounds.zMax) / 2
239
+ ]
240
+ });
241
+ }
242
+ bores.forEach((bore, i) => {
243
+ marks.push({
244
+ label: `H${i + 1}`,
245
+ pos: bore.axisOrigin
246
+ });
247
+ });
248
+ return marks;
249
+ }
250
+ /**
251
+ * Aim a section cut through the dominant (largest-radius) bore: cut on the basis axis MOST
252
+ * perpendicular to the bore axis, positioned to pass THROUGH the bore origin — so the bore opens as
253
+ * a clean cross-section (reads wall thickness, where the xray is cluttered). Returns null when there
254
+ * are no bores or no bounds. Plain structural inputs keep this decoupled from the verify report.
255
+ */
256
+ function aimedSection(bores, bounds) {
257
+ if (bores.length === 0 || !bounds) return null;
258
+ const bore = bores.reduce((a, b) => b.radius > a.radius ? b : a);
259
+ const d = bore.axisDir;
260
+ const perp = {
261
+ x: Math.abs(d[0]),
262
+ y: Math.abs(d[1]),
263
+ z: Math.abs(d[2])
264
+ };
265
+ const axis = [
266
+ "x",
267
+ "y",
268
+ "z"
269
+ ].reduce((a, b) => perp[b] < perp[a] ? b : a);
270
+ const i = axis === "x" ? 0 : axis === "y" ? 1 : 2;
271
+ const min = axis === "x" ? bounds.xMin : axis === "y" ? bounds.yMin : bounds.zMin;
272
+ const span = (axis === "x" ? bounds.xMax : axis === "y" ? bounds.yMax : bounds.zMax) - min || 1;
273
+ return {
274
+ axis,
275
+ frac: Math.min(1, Math.max(0, (bore.axisOrigin[i] - min) / span))
276
+ };
277
+ }
278
+ //#endregion
218
279
  //#region src/cli/main.ts
219
280
  console.log = (...args) => {
220
281
  process.stderr.write(args.map(String).join(" ") + "\n");
@@ -230,7 +291,7 @@ async function loadSnapshotShoot() {
230
291
  }
231
292
  var program = new commander.Command();
232
293
  program.name("brep");
233
- program.command("verify", { isDefault: true }).argument("<files...>", "path(s) or glob(s) to .brep.ts modules; multiple files → a batch validity JSON array").option("--step <out>", "write the primary STEP artifact to this path").option("--glb <out>", "write a derived GLB preview to this path").option("--json <out>", "write the JSON report to this path").option("--check", "type-check the part (against brepjs types) before running; skip execution on type errors").option("--snapshot <dir>", "render iso/front/top/right PNGs to this dir (requires built viewer)").option("--serve", "after verifying, start a preview server and print a ?dir=&file= deep link (stays running)").option("--no-open", "with --serve, do not auto-open the browser (just print the viewer URL)").option("--expect-code <code>", "exit 0 only if the report emits this error code (for fixtures/eval recall)").option("--expect-invalid", "exit 0 only if the part is invalid (ok:false) — for known-bad fixtures").action(async (files, opts) => {
294
+ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) or glob(s) to .brep.ts modules; multiple files → a batch validity JSON array").option("--step <out>", "write the primary STEP artifact to this path").option("--glb <out>", "write a derived GLB preview to this path").option("--json <out>", "write the JSON report to this path").option("--check", "type-check the part (against brepjs types) before running; skip execution on type errors").option("--snapshot <dir>", "render iso/front/top/right PNGs to this dir (requires built viewer)").option("--serve", "after verifying, start a preview server and print a ?dir=&file= deep link (stays running)").option("--no-open", "with --serve, do not auto-open the browser (just print the viewer URL)").option("--expect-code <code>", "exit 0 only if the report emits this error code (for fixtures/eval recall)").option("--expect-invalid", "exit 0 only if the part is invalid (ok:false) — for known-bad fixtures").option("--metrics", "compute deterministic manufacturability metrics (per-body breakdown + interference matrix) for the design judge — slower; off by default").action(async (files, opts) => {
234
295
  const targets = [...new Set(files.flatMap((f) => {
235
296
  const m = (0, node_fs.globSync)(f);
236
297
  return m.length ? m : [f];
@@ -239,7 +300,10 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
239
300
  if (opts.step || opts.serve || opts.snapshot || opts.glb || opts.expectCode || opts.expectInvalid) process.stderr.write("batch mode: --step/--glb/--serve/--snapshot/--expect-code/--expect-invalid ignored (single-file only)\n");
240
301
  const results = [];
241
302
  for (const f of targets) {
242
- const { report, shape } = await require_diff.runPart(f, { check: Boolean(opts.check) });
303
+ const { report, shape } = await require_diff.runPart(f, {
304
+ check: Boolean(opts.check),
305
+ metrics: Boolean(opts.metrics)
306
+ });
243
307
  disposeShape(shape);
244
308
  results.push({
245
309
  file: f,
@@ -258,7 +322,8 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
258
322
  const { report, step, glb, shape } = await require_diff.runPart((0, node_path.resolve)(file), {
259
323
  step: wantStep,
260
324
  glb: Boolean(opts.glb),
261
- check: Boolean(opts.check)
325
+ check: Boolean(opts.check),
326
+ metrics: Boolean(opts.metrics) || Boolean(opts.snapshot)
262
327
  });
263
328
  let stepPath = opts.step;
264
329
  try {
@@ -270,9 +335,13 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
270
335
  if (opts.snapshot && stepPath) {
271
336
  const shoot = await loadSnapshotShoot();
272
337
  if (shoot) {
338
+ const section = aimedSection(report.manufacturability?.bores ?? [], report.measurements.bounds);
339
+ const marks = featureMarks(report.bodies ?? [], report.manufacturability?.bores ?? []);
273
340
  const { pngs } = await shoot({
274
341
  file: stepPath,
275
- outDir: opts.snapshot
342
+ outDir: opts.snapshot,
343
+ ...section ? { section } : {},
344
+ ...marks.length > 0 ? { marks } : {}
276
345
  });
277
346
  for (const p of pngs) process.stderr.write(`snapshot: ${p}\n`);
278
347
  }
@@ -321,7 +390,10 @@ program.command("snapshot").argument("<file>", "path to a .brep.ts module; rende
321
390
  const resolved = (0, node_path.resolve)(file);
322
391
  const base = opts.out ? (0, node_path.resolve)(opts.out) : `${resolved}-shots`;
323
392
  const outDir = opts.label ? (0, node_path.join)(base, opts.label) : base;
324
- const { report, step, shape } = await require_diff.runPart(resolved, { step: true });
393
+ const { report, step, shape } = await require_diff.runPart(resolved, {
394
+ step: true,
395
+ metrics: true
396
+ });
325
397
  try {
326
398
  if (!step) {
327
399
  process.stderr.write("snapshot: part produced no STEP — nothing to render\n");
@@ -336,10 +408,14 @@ program.command("snapshot").argument("<file>", "path to a .brep.ts module; rende
336
408
  process.stdout.write(require_diff.serializeReport(report) + "\n");
337
409
  return;
338
410
  }
411
+ const section = aimedSection(report.manufacturability?.bores ?? [], report.measurements.bounds);
412
+ const marks = featureMarks(report.bodies ?? [], report.manufacturability?.bores ?? []);
339
413
  const { pngs } = await shoot({
340
414
  file: stepPath,
341
415
  outDir,
342
- fresh: true
416
+ fresh: true,
417
+ ...section ? { section } : {},
418
+ ...marks.length > 0 ? { marks } : {}
343
419
  });
344
420
  for (const p of pngs) process.stdout.write(`${p}\n`);
345
421
  } finally {
package/dist/cli/main.js CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { f as pushError, h as loadBrep, m as serializeReport, n as runMeasure, p as reportOk, r as runPart, t as runDiff } from "../diff-DcKUadBa.js";
2
+ import { f as pushError, h as loadBrep, m as serializeReport, n as runMeasure, p as reportOk, r as runPart, t as runDiff } from "../diff-MBAc87Xi.js";
3
3
  import { fileURLToPath } from "node:url";
4
4
  import { existsSync, globSync, mkdirSync, realpathSync, watch, writeFileSync } from "node:fs";
5
5
  import { basename, dirname, join, resolve } from "node:path";
@@ -214,6 +214,67 @@ function openBrowser(url, platform = process.platform) {
214
214
  } catch {}
215
215
  }
216
216
  //#endregion
217
+ //#region src/snapshot/aiming.ts
218
+ /**
219
+ * Kernel-anchored marks for the judge: `B<index>` at each body's bbox centroid (only for multi-body
220
+ * parts — a single body needs no label) and `H1..` at each bore's axis. The same id lands on the same
221
+ * feature across every view (it's a 3D anchor projected per-view), giving the judge view-invariant,
222
+ * part-space addressing without per-body mesh color.
223
+ *
224
+ * Body labels use the body's INDEX (0-based), matching how the measured-facts digest refers to bodies
225
+ * ("bodies 0&1: interfering"), so the judge can cross-reference a mark with the facts. A body that
226
+ * can't be located (no bounds) simply gets no mark — there is no resequenced gap.
227
+ */
228
+ function featureMarks(bodies, bores) {
229
+ const marks = [];
230
+ if (bodies.length > 1) for (const b of bodies) {
231
+ if (!b.bounds) continue;
232
+ marks.push({
233
+ label: `B${b.index}`,
234
+ pos: [
235
+ (b.bounds.xMin + b.bounds.xMax) / 2,
236
+ (b.bounds.yMin + b.bounds.yMax) / 2,
237
+ (b.bounds.zMin + b.bounds.zMax) / 2
238
+ ]
239
+ });
240
+ }
241
+ bores.forEach((bore, i) => {
242
+ marks.push({
243
+ label: `H${i + 1}`,
244
+ pos: bore.axisOrigin
245
+ });
246
+ });
247
+ return marks;
248
+ }
249
+ /**
250
+ * Aim a section cut through the dominant (largest-radius) bore: cut on the basis axis MOST
251
+ * perpendicular to the bore axis, positioned to pass THROUGH the bore origin — so the bore opens as
252
+ * a clean cross-section (reads wall thickness, where the xray is cluttered). Returns null when there
253
+ * are no bores or no bounds. Plain structural inputs keep this decoupled from the verify report.
254
+ */
255
+ function aimedSection(bores, bounds) {
256
+ if (bores.length === 0 || !bounds) return null;
257
+ const bore = bores.reduce((a, b) => b.radius > a.radius ? b : a);
258
+ const d = bore.axisDir;
259
+ const perp = {
260
+ x: Math.abs(d[0]),
261
+ y: Math.abs(d[1]),
262
+ z: Math.abs(d[2])
263
+ };
264
+ const axis = [
265
+ "x",
266
+ "y",
267
+ "z"
268
+ ].reduce((a, b) => perp[b] < perp[a] ? b : a);
269
+ const i = axis === "x" ? 0 : axis === "y" ? 1 : 2;
270
+ const min = axis === "x" ? bounds.xMin : axis === "y" ? bounds.yMin : bounds.zMin;
271
+ const span = (axis === "x" ? bounds.xMax : axis === "y" ? bounds.yMax : bounds.zMax) - min || 1;
272
+ return {
273
+ axis,
274
+ frac: Math.min(1, Math.max(0, (bore.axisOrigin[i] - min) / span))
275
+ };
276
+ }
277
+ //#endregion
217
278
  //#region src/cli/main.ts
218
279
  console.log = (...args) => {
219
280
  process.stderr.write(args.map(String).join(" ") + "\n");
@@ -229,7 +290,7 @@ async function loadSnapshotShoot() {
229
290
  }
230
291
  var program = new Command();
231
292
  program.name("brep");
232
- program.command("verify", { isDefault: true }).argument("<files...>", "path(s) or glob(s) to .brep.ts modules; multiple files → a batch validity JSON array").option("--step <out>", "write the primary STEP artifact to this path").option("--glb <out>", "write a derived GLB preview to this path").option("--json <out>", "write the JSON report to this path").option("--check", "type-check the part (against brepjs types) before running; skip execution on type errors").option("--snapshot <dir>", "render iso/front/top/right PNGs to this dir (requires built viewer)").option("--serve", "after verifying, start a preview server and print a ?dir=&file= deep link (stays running)").option("--no-open", "with --serve, do not auto-open the browser (just print the viewer URL)").option("--expect-code <code>", "exit 0 only if the report emits this error code (for fixtures/eval recall)").option("--expect-invalid", "exit 0 only if the part is invalid (ok:false) — for known-bad fixtures").action(async (files, opts) => {
293
+ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) or glob(s) to .brep.ts modules; multiple files → a batch validity JSON array").option("--step <out>", "write the primary STEP artifact to this path").option("--glb <out>", "write a derived GLB preview to this path").option("--json <out>", "write the JSON report to this path").option("--check", "type-check the part (against brepjs types) before running; skip execution on type errors").option("--snapshot <dir>", "render iso/front/top/right PNGs to this dir (requires built viewer)").option("--serve", "after verifying, start a preview server and print a ?dir=&file= deep link (stays running)").option("--no-open", "with --serve, do not auto-open the browser (just print the viewer URL)").option("--expect-code <code>", "exit 0 only if the report emits this error code (for fixtures/eval recall)").option("--expect-invalid", "exit 0 only if the part is invalid (ok:false) — for known-bad fixtures").option("--metrics", "compute deterministic manufacturability metrics (per-body breakdown + interference matrix) for the design judge — slower; off by default").action(async (files, opts) => {
233
294
  const targets = [...new Set(files.flatMap((f) => {
234
295
  const m = globSync(f);
235
296
  return m.length ? m : [f];
@@ -238,7 +299,10 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
238
299
  if (opts.step || opts.serve || opts.snapshot || opts.glb || opts.expectCode || opts.expectInvalid) process.stderr.write("batch mode: --step/--glb/--serve/--snapshot/--expect-code/--expect-invalid ignored (single-file only)\n");
239
300
  const results = [];
240
301
  for (const f of targets) {
241
- const { report, shape } = await runPart(f, { check: Boolean(opts.check) });
302
+ const { report, shape } = await runPart(f, {
303
+ check: Boolean(opts.check),
304
+ metrics: Boolean(opts.metrics)
305
+ });
242
306
  disposeShape(shape);
243
307
  results.push({
244
308
  file: f,
@@ -257,7 +321,8 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
257
321
  const { report, step, glb, shape } = await runPart(resolve(file), {
258
322
  step: wantStep,
259
323
  glb: Boolean(opts.glb),
260
- check: Boolean(opts.check)
324
+ check: Boolean(opts.check),
325
+ metrics: Boolean(opts.metrics) || Boolean(opts.snapshot)
261
326
  });
262
327
  let stepPath = opts.step;
263
328
  try {
@@ -269,9 +334,13 @@ program.command("verify", { isDefault: true }).argument("<files...>", "path(s) o
269
334
  if (opts.snapshot && stepPath) {
270
335
  const shoot = await loadSnapshotShoot();
271
336
  if (shoot) {
337
+ const section = aimedSection(report.manufacturability?.bores ?? [], report.measurements.bounds);
338
+ const marks = featureMarks(report.bodies ?? [], report.manufacturability?.bores ?? []);
272
339
  const { pngs } = await shoot({
273
340
  file: stepPath,
274
- outDir: opts.snapshot
341
+ outDir: opts.snapshot,
342
+ ...section ? { section } : {},
343
+ ...marks.length > 0 ? { marks } : {}
275
344
  });
276
345
  for (const p of pngs) process.stderr.write(`snapshot: ${p}\n`);
277
346
  }
@@ -320,7 +389,10 @@ program.command("snapshot").argument("<file>", "path to a .brep.ts module; rende
320
389
  const resolved = resolve(file);
321
390
  const base = opts.out ? resolve(opts.out) : `${resolved}-shots`;
322
391
  const outDir = opts.label ? join(base, opts.label) : base;
323
- const { report, step, shape } = await runPart(resolved, { step: true });
392
+ const { report, step, shape } = await runPart(resolved, {
393
+ step: true,
394
+ metrics: true
395
+ });
324
396
  try {
325
397
  if (!step) {
326
398
  process.stderr.write("snapshot: part produced no STEP — nothing to render\n");
@@ -335,10 +407,14 @@ program.command("snapshot").argument("<file>", "path to a .brep.ts module; rende
335
407
  process.stdout.write(serializeReport(report) + "\n");
336
408
  return;
337
409
  }
410
+ const section = aimedSection(report.manufacturability?.bores ?? [], report.measurements.bounds);
411
+ const marks = featureMarks(report.bodies ?? [], report.manufacturability?.bores ?? []);
338
412
  const { pngs } = await shoot({
339
413
  file: stepPath,
340
414
  outDir,
341
- fresh: true
415
+ fresh: true,
416
+ ...section ? { section } : {},
417
+ ...marks.length > 0 ? { marks } : {}
342
418
  });
343
419
  for (const p of pngs) process.stdout.write(`${p}\n`);
344
420
  } finally {
@@ -50,6 +50,199 @@ function initOcctWasm(brep) {
50
50
  return kernelReady;
51
51
  }
52
52
  //#endregion
53
+ //#region src/verify/manufacturability.ts
54
+ var RELATION_BUDGET_MS = 15e3;
55
+ var MAX_BODIES = 24;
56
+ var VOL_EPS = 1e-9;
57
+ var SUBSTANTIAL_USPAN = 2.5;
58
+ var BORE_DOT = .5;
59
+ var v3sub = (a, b) => [
60
+ a[0] - b[0],
61
+ a[1] - b[1],
62
+ a[2] - b[2]
63
+ ];
64
+ var v3dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
65
+ var v3scale = (a, s) => [
66
+ a[0] * s,
67
+ a[1] * s,
68
+ a[2] * s
69
+ ];
70
+ var v3unit = (a) => {
71
+ const n = Math.hypot(a[0], a[1], a[2]) || 1;
72
+ return [
73
+ a[0] / n,
74
+ a[1] / n,
75
+ a[2] / n
76
+ ];
77
+ };
78
+ function diagonalOf(boundsList) {
79
+ if (boundsList.length === 0) return 1;
80
+ const u = {
81
+ xMin: Math.min(...boundsList.map((b) => b.xMin)),
82
+ yMin: Math.min(...boundsList.map((b) => b.yMin)),
83
+ zMin: Math.min(...boundsList.map((b) => b.zMin)),
84
+ xMax: Math.max(...boundsList.map((b) => b.xMax)),
85
+ yMax: Math.max(...boundsList.map((b) => b.yMax)),
86
+ zMax: Math.max(...boundsList.map((b) => b.zMax))
87
+ };
88
+ return Math.hypot(u.xMax - u.xMin, u.yMax - u.yMin, u.zMax - u.zMin) || 1;
89
+ }
90
+ function aabbDisjoint(a, b, eps) {
91
+ return a.xMax + eps < b.xMin || b.xMax + eps < a.xMin || a.yMax + eps < b.yMin || b.yMax + eps < a.yMin || a.zMax + eps < b.zMin || b.zMax + eps < a.zMin;
92
+ }
93
+ /** Per-body volume/bounds/validity. Used both for the report and to drive the relation matrix. */
94
+ function computeBodies(brep, solids) {
95
+ const { measureVolume, getBounds, validSolid, isOk } = brep;
96
+ return solids.map((s, index) => {
97
+ const v = measureVolume(s);
98
+ const volume = isOk(v) ? v.value : 0;
99
+ let bounds;
100
+ try {
101
+ bounds = getBounds(s);
102
+ } catch {}
103
+ return {
104
+ index,
105
+ volume,
106
+ ...bounds ? { bounds } : {},
107
+ valid: isOk(validSolid(s))
108
+ };
109
+ });
110
+ }
111
+ /**
112
+ * Pairwise body relationships. `separate` when the bodies are apart (AABB-disjoint, or measured
113
+ * clearance above eps); `interfering` when they touch or overlap (clearance within eps). `touching`/
114
+ * `nested` (which need a boolean-volume tier) are reserved for a later phase. eps scales to the
115
+ * assembly diagonal so it is meaningful from millimetre parts to metre-scale BIM.
116
+ */
117
+ function computeBodyRelations(brep, solids, bodies) {
118
+ const { checkInterference, isOk } = brep;
119
+ const relations = [];
120
+ const eps = Math.max(1e-6, 1e-4 * diagonalOf(bodies.flatMap((b) => b.bounds ? [b.bounds] : [])));
121
+ const start = Date.now();
122
+ for (let i = 0; i < solids.length; i++) for (let j = i + 1; j < solids.length; j++) {
123
+ if (Date.now() - start > RELATION_BUDGET_MS) return {
124
+ relations,
125
+ truncated: true
126
+ };
127
+ const bi = bodies[i]?.bounds;
128
+ const bj = bodies[j]?.bounds;
129
+ if (bi && bj && aabbDisjoint(bi, bj, eps)) {
130
+ relations.push({
131
+ a: i,
132
+ b: j,
133
+ relation: "separate"
134
+ });
135
+ continue;
136
+ }
137
+ const si = solids[i];
138
+ const sj = solids[j];
139
+ if (!si || !sj) continue;
140
+ const res = checkInterference(si, sj, eps);
141
+ if (!isOk(res)) continue;
142
+ relations.push({
143
+ a: i,
144
+ b: j,
145
+ relation: res.value.hasInterference ? "interfering" : "separate",
146
+ clearance: res.value.minDistance
147
+ });
148
+ }
149
+ return {
150
+ relations,
151
+ truncated: false
152
+ };
153
+ }
154
+ /**
155
+ * Cylindrical features: the smallest substantial cylinder radius (bore/shaft; fillet strips excluded
156
+ * by angular extent) and the internal bores (concave, detected by an outward-normal-vs-radial test).
157
+ * The bore axis lets a later phase aim a section cut or anchor a feature mark.
158
+ */
159
+ function computeCylinderFeatures(brep, shape) {
160
+ const { getFaces, faceGeomType, faceCenter, normalAt, faceOrientation, faceAxis, measureCurvatureAtMid, uvBounds, isOk } = brep;
161
+ const bores = [];
162
+ let minRadius;
163
+ let faces;
164
+ try {
165
+ faces = getFaces(shape);
166
+ } catch {
167
+ return { bores };
168
+ }
169
+ for (const f of faces) {
170
+ if ((() => {
171
+ try {
172
+ return faceGeomType(f);
173
+ } catch {
174
+ return null;
175
+ }
176
+ })() !== "CYLINDRE") continue;
177
+ const ax = faceAxis(f);
178
+ if (!ax) continue;
179
+ const curv = measureCurvatureAtMid(f);
180
+ if (!isOk(curv)) continue;
181
+ const k = Math.max(Math.abs(curv.value.maxCurvature), Math.abs(curv.value.minCurvature));
182
+ if (k <= 0) continue;
183
+ const radius = 1 / k;
184
+ try {
185
+ const uv = uvBounds(f);
186
+ if (uv.uMax - uv.uMin <= SUBSTANTIAL_USPAN) continue;
187
+ } catch {
188
+ continue;
189
+ }
190
+ minRadius = minRadius === void 0 ? radius : Math.min(minRadius, radius);
191
+ try {
192
+ const p = faceCenter(f);
193
+ const o = ax.origin;
194
+ const d = v3unit(ax.direction);
195
+ const rel = v3sub(p, o);
196
+ const radial = v3unit(v3sub(rel, v3scale(d, v3dot(rel, d))));
197
+ const n = normalAt(f);
198
+ if (v3dot(v3unit(faceOrientation(f) === "forward" ? n : v3scale(n, -1)), radial) > BORE_DOT) bores.push({
199
+ radius,
200
+ axisOrigin: [
201
+ o[0],
202
+ o[1],
203
+ o[2]
204
+ ],
205
+ axisDir: [
206
+ d[0],
207
+ d[1],
208
+ d[2]
209
+ ]
210
+ });
211
+ } catch {}
212
+ }
213
+ return {
214
+ ...minRadius !== void 0 ? { minRadius } : {},
215
+ bores
216
+ };
217
+ }
218
+ /** Orchestrate the deterministic metrics for a verified shape. Never throws (caller wraps too). */
219
+ function computeMetrics(brep, shape, solids) {
220
+ const bodies = computeBodies(brep, solids);
221
+ const violations = [];
222
+ bodies.forEach((b) => {
223
+ if (b.volume <= VOL_EPS) violations.push(`body ${b.index} has ~zero volume (degenerate)`);
224
+ });
225
+ const manufacturability = { violations };
226
+ const feat = computeCylinderFeatures(brep, shape);
227
+ if (feat.minRadius !== void 0) manufacturability.minRadius = feat.minRadius;
228
+ if (feat.bores.length > 0) manufacturability.bores = feat.bores;
229
+ if (bodies.length <= 1) return { manufacturability };
230
+ if (bodies.length > MAX_BODIES) {
231
+ manufacturability.relationsTruncated = true;
232
+ return {
233
+ bodies,
234
+ manufacturability
235
+ };
236
+ }
237
+ const { relations, truncated } = computeBodyRelations(brep, solids, bodies);
238
+ if (truncated) manufacturability.relationsTruncated = true;
239
+ return {
240
+ bodies,
241
+ bodyRelations: relations,
242
+ manufacturability
243
+ };
244
+ }
245
+ //#endregion
53
246
  //#region src/verify/report.ts
54
247
  function emptyReport() {
55
248
  return {
@@ -104,6 +297,10 @@ var HINT_TABLE = {
104
297
  fix: "Pass a non-zero thickness to `.extrude(h)` — a height of 0 is a zero-length vector, no solid.",
105
298
  nextStep: "Set a positive extrusion height (units: mm) and re-verify."
106
299
  },
300
+ DEGENERATE_EDGE: {
301
+ fix: "A polygon/wire has coincident consecutive points (a zero-length edge). Dedupe the point list before building — common in computed tooth/gear loops where a land and a groove point land on the same coordinate.",
302
+ nextStep: "Remove duplicate consecutive points from the profile (a tooth’s trailing point is the next tooth’s leading point), then re-verify."
303
+ },
107
304
  ZERO_OFFSET: {
108
305
  fix: "Offset by a non-zero amount — an offset of 0 is a no-op the kernel rejects.",
109
306
  nextStep: "Use a small non-zero offset (positive grows, negative shrinks) and re-verify."
@@ -264,10 +461,11 @@ function shapeTypeOf(brep, s) {
264
461
  if (isVertex(s)) return "Vertex";
265
462
  return "Unknown";
266
463
  }
267
- function runChecks(brep, shape) {
464
+ function runChecks(brep, shape, opts = {}) {
268
465
  const { isSolid, isShape3D, isFace, measureVolumeProps, measureArea, getBounds, getFaces, getEdges, getWires, getVertices, getSolids, getShells, isManifoldShell, validSolid, isOk } = brep;
269
466
  const r = emptyReport();
270
467
  r.shapeType = shapeTypeOf(brep, shape);
468
+ const solids = isSolid(shape) ? [shape] : getSolids(shape);
271
469
  if (isSolid(shape)) {
272
470
  const valid = validSolid(shape);
273
471
  const validCheck = {
@@ -282,28 +480,26 @@ function runChecks(brep, shape) {
282
480
  });
283
481
  }
284
482
  r.checks.push(validCheck);
285
- } else {
286
- const solids = getSolids(shape);
287
- if (solids.length > 0) {
288
- const failures = [];
289
- solids.forEach((s, i) => {
290
- const v = validSolid(s);
291
- if (!isOk(v)) failures.push(`body ${i}: ${v.error}`);
483
+ } else if (solids.length > 0) {
484
+ const failures = [];
485
+ solids.forEach((s, i) => {
486
+ const v = validSolid(s);
487
+ if (!isOk(v)) failures.push(`body ${i}: ${v.error}`);
488
+ });
489
+ const bodiesCheck = {
490
+ name: "allBodiesValid",
491
+ passed: failures.length === 0
492
+ };
493
+ if (failures.length > 0) {
494
+ bodiesCheck.detail = `${failures.length}/${solids.length} bodies invalid — ${failures.join("; ")}`;
495
+ r.errorInfos.push({
496
+ message: `allBodiesValid: ${bodiesCheck.detail}`,
497
+ code: VALIDITY_FAILURE_CODE
292
498
  });
293
- const bodiesCheck = {
294
- name: "allBodiesValid",
295
- passed: failures.length === 0
296
- };
297
- if (failures.length > 0) {
298
- bodiesCheck.detail = `${failures.length}/${solids.length} bodies invalid — ${failures.join("; ")}`;
299
- r.errorInfos.push({
300
- message: `allBodiesValid: ${bodiesCheck.detail}`,
301
- code: VALIDITY_FAILURE_CODE
302
- });
303
- }
304
- r.checks.push(bodiesCheck);
305
499
  }
500
+ r.checks.push(bodiesCheck);
306
501
  }
502
+ if (!isSolid(shape) && solids.length > 1) (r.notes ??= []).push(`shapeType is ${r.shapeType} with ${solids.length} solids. If this is meant to be ONE part, the bodies are NOT welded — give the operands a real overlap and fuseAll(shapes, { unsafe: true }). If it is a deliberate assembly, this is expected. Run --metrics to see whether the bodies touch (a failed weld) or sit apart (an assembly).`);
307
503
  if (isShape3D(shape)) {
308
504
  const volProps = measureVolumeProps(shape);
309
505
  if (isOk(volProps)) {
@@ -340,6 +536,12 @@ function runChecks(brep, shape) {
340
536
  const shells = getShells(shape);
341
537
  if (shells.length > 0) r.topology.manifold = shells.every((s) => isManifoldShell(s));
342
538
  } catch {}
539
+ if (opts.metrics) try {
540
+ const m = computeMetrics(brep, shape, solids);
541
+ if (m.bodies) r.bodies = m.bodies;
542
+ if (m.bodyRelations) r.bodyRelations = m.bodyRelations;
543
+ r.manufacturability = m.manufacturability;
544
+ } catch {}
343
545
  r.hints = buildHints(r);
344
546
  return r;
345
547
  }
@@ -625,7 +827,7 @@ function toErrorInfo(prefix, e) {
625
827
  suggestion: e.suggestion
626
828
  };
627
829
  if (e instanceof Error) {
628
- const code = e.message.match(/\[[A-Z][A-Z0-9_]*\]\s+([A-Z][A-Z0-9_]+):/)?.[1];
830
+ const code = e.message.match(/\[[A-Z][A-Z0-9_]*\]\s+([A-Z][A-Z0-9_]+):/)?.[1] ?? classifyKernelMessage(e.message);
629
831
  return code ? {
630
832
  message: `${prefix}: ${e.message}`,
631
833
  code
@@ -633,6 +835,9 @@ function toErrorInfo(prefix, e) {
633
835
  }
634
836
  return { message: `${prefix}: ${String(e)}` };
635
837
  }
838
+ function classifyKernelMessage(message) {
839
+ if (/makeLineEdge: construction failed/i.test(message)) return "DEGENERATE_EDGE";
840
+ }
636
841
  function finalize(result) {
637
842
  result.report.hints = buildHints(result.report);
638
843
  return result;
@@ -706,7 +911,7 @@ async function runPart(modulePath, opts = {}) {
706
911
  report
707
912
  });
708
913
  }
709
- const result = runChecks(brep, shape);
914
+ const result = runChecks(brep, shape, { metrics: Boolean(opts.metrics) });
710
915
  if (isExpectedDims(mod.expected)) {
711
916
  const expected = mod.expected;
712
917
  result.assertions = evaluateExpected(expected, result.measurements);