partforge 0.62.1 → 0.64.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.
Files changed (39) hide show
  1. package/bin/cli.js +2 -3
  2. package/docs/AUTHORING-PARTS.md +158 -0
  3. package/docs/ERROR-PATTERNS.md +35 -1
  4. package/docs/KERNEL-CONTRACT.md +38 -2
  5. package/package.json +1 -1
  6. package/src/app-import-demo.js +18 -0
  7. package/src/framework/app.css +8 -1
  8. package/src/framework/asset-resolve.js +71 -0
  9. package/src/framework/capture-build.js +12 -4
  10. package/src/framework/export-controller.js +12 -0
  11. package/src/framework/fonts.js +12 -30
  12. package/src/framework/geometry/kernel.js +4 -3
  13. package/src/framework/geometry/manifold-backend.js +50 -0
  14. package/src/framework/geometry/mesh-repair.js +87 -0
  15. package/src/framework/geometry/mesh-roundall.js +83 -0
  16. package/src/framework/geometry/occt-backend.js +42 -1
  17. package/src/framework/geometry/occt-roundall.js +94 -0
  18. package/src/framework/geometry/op-options.js +2 -0
  19. package/src/framework/geometry/stl-parse.js +45 -0
  20. package/src/framework/geometry/threemf-parse.js +87 -0
  21. package/src/framework/geometry-service.js +3 -1
  22. package/src/framework/imports.js +84 -0
  23. package/src/framework/jobs.js +20 -1
  24. package/src/framework/lint/index.js +2 -1
  25. package/src/framework/lint/rules-imports.js +115 -0
  26. package/src/framework/mount.js +94 -1
  27. package/src/framework/oracle/measure.js +28 -2
  28. package/src/framework/verify-metrics.js +10 -0
  29. package/src/framework/worker.js +11 -2
  30. package/src/import-demo-worker.js +3 -0
  31. package/src/parts/assets/import-demo-scan.stl +86 -0
  32. package/src/parts/import-demo.js +134 -0
  33. package/src/testing/assets.js +19 -0
  34. package/src/testing/manifold.js +14 -2
  35. package/src/testing/occt.js +5 -2
  36. package/src/testing/step-mesh-thread.js +15 -0
  37. package/src/testing/step-mesh.js +17 -0
  38. package/types/kernel.d.ts +12 -0
  39. package/types/part.d.ts +14 -0
@@ -0,0 +1,134 @@
1
+ // Reference part for docs/AUTHORING-PARTS.md's "Importing geometry" section —
2
+ // the worked example for BOTH import uses in one part:
3
+ // • reference — `ref` is a translucent ghost of the imported scan (never
4
+ // exported); `body` is a parametric rebuild of the same block, bound to
5
+ // the scan via `reference: "scan"` and held to it by the three ref*
6
+ // deviation metrics in `verify.expect.body`.
7
+ // • component — `mount` uses the import as a real boolean tool: a plate
8
+ // with a through-socket cut to the scan's own shape (scaled up by `fit`
9
+ // for clearance), exported like any other sub-part.
10
+ // `src/parts/assets/import-demo-scan.stl` is a small hand-written ascii STL —
11
+ // a 20×14×8 mm block, origin-cornered, outward-wound. `body`'s defaults
12
+ // reproduce those dimensions exactly, so the deviation gate passes with real
13
+ // margin. Open /import-demo.html after `npm run dev`.
14
+ export default {
15
+ meta: { title: "Import demo", units: "mm", background: 0x15181d },
16
+ imports: {
17
+ scan: new URL("./assets/import-demo-scan.stl", import.meta.url),
18
+ },
19
+ parameters: [
20
+ {
21
+ id: "scan",
22
+ title: "Scan",
23
+ description: "Dimensions of the parametric rebuild (`body`). Defaults match the imported reference block exactly — drag one and watch the deviation gate in `partforge measure` react.",
24
+ advanced: [
25
+ { key: "scanW", label: "Width", unit: "mm", min: 10, max: 40, step: 0.5,
26
+ description: "Rebuild width (X). Matches the scan's width at the default." },
27
+ { key: "scanD", label: "Depth", unit: "mm", min: 10, max: 40, step: 0.5,
28
+ description: "Rebuild depth (Y). Matches the scan's depth at the default." },
29
+ { key: "scanH", label: "Height", unit: "mm", min: 4, max: 20, step: 0.5,
30
+ description: "Rebuild height (Z). Matches the scan's height at the default." },
31
+ ],
32
+ },
33
+ {
34
+ id: "mount",
35
+ title: "Mount",
36
+ description: "A plate with a through-socket cut to the scan's own shape — the import used as a real boolean component, not just a reference.",
37
+ advanced: [
38
+ { key: "fit", label: "Socket clearance", unit: "×", min: 1, max: 1.2, step: 0.01,
39
+ description: "Uniform scale applied to the imported scan before it's used as the cutting tool, so the block seats with a slip fit." },
40
+ { key: "margin", label: "Plate margin", unit: "mm", min: 1, max: 10, step: 0.5,
41
+ description: "Solid plate border kept around the socket on every side." },
42
+ // The socket is the scan's own (fixed, ~8mm-tall) imported geometry scaled
43
+ // by `fit` and overcut 1mm past the plate's bottom face — see `mount.build`
44
+ // below. It only stays a genuine through-hole (not a blind pocket) while
45
+ // plateH < scanH*fit - 1; at the worst-case corner (fit at its slider
46
+ // minimum, 1) that's plateH < 7. max is capped at 6.5, half a millimetre
47
+ // inside that bound, so every reachable (plateH, fit) combination — not
48
+ // just the defaults — keeps `mount: { holes: 1 }` true.
49
+ { key: "plateH", label: "Plate thickness", unit: "mm", min: 2, max: 6.5, step: 0.5,
50
+ description: "Mount plate thickness. The socket cuts all the way through it." },
51
+ { key: "gap", label: "Gap from rebuild", unit: "mm", min: 5, max: 30, step: 1,
52
+ description: "Presentation-only spacing between `body` and `mount` in the assembly view, so the two never overlap." },
53
+ ],
54
+ },
55
+ ],
56
+ defaults: { scanW: 20, scanD: 14, scanH: 8, fit: 1.05, margin: 3, plateH: 4, gap: 10 },
57
+ // mountOffsetX: how far along X the mount plate sits from the rebuild, so
58
+ // the two solids never interpenetrate regardless of the current dimensions.
59
+ derive: (p) => ({ mountOffsetX: p.scanW + p.gap }),
60
+ parts: {
61
+ // Ghost overlay of the raw import — ref* deviation is computed against
62
+ // this by name (`reference: "scan"` on `body`, below) regardless of which
63
+ // view is active, so `ref` only needs to appear in the "reference" view,
64
+ // where it's shown translucent over `body` for visual alignment checking.
65
+ // It is deliberately absent from "assembly": measure()'s `ok` requires
66
+ // zero sub-part overlaps in the measured view, and a ghost coincident
67
+ // with its rebuild always overlaps by design.
68
+ ref: {
69
+ label: "Reference (ghost)",
70
+ views: ["reference"],
71
+ exportable: false,
72
+ display: { opacity: 0.3 },
73
+ build: (k) => k.import("scan"),
74
+ },
75
+ // Parametric rebuild of the scanned block. `reference: "scan"` tells
76
+ // measure() to compute deviation facts (xorVolume / volumeDeltaPct /
77
+ // bboxDelta) against the import; verify.expect.body below gates on them.
78
+ // Shown in both views: alone (fitted with `mount`) in "assembly", and
79
+ // against the ghost in "reference".
80
+ body: {
81
+ label: "Rebuild",
82
+ views: ["assembly", "reference"],
83
+ export: { name: "body" },
84
+ reference: "scan",
85
+ build: (k, p) => k.box({ min: [0, 0, 0], max: [p.scanW, p.scanD, p.scanH] }),
86
+ },
87
+ // The import used as a real component: a plate with a through-socket cut
88
+ // to the (clearance-scaled) scan shape. Offset along X so it never
89
+ // overlaps `body` in the assembly view.
90
+ mount: {
91
+ label: "Mount plate",
92
+ views: ["assembly"],
93
+ export: { name: "mount" },
94
+ build: (k, p, d) => {
95
+ const plate = k.box({
96
+ min: [d.mountOffsetX - p.margin, -p.margin, -p.plateH],
97
+ max: [d.mountOffsetX + p.scanW * p.fit + p.margin, p.scanD * p.fit + p.margin, 0],
98
+ });
99
+ const socket = k.import("scan")
100
+ .scale(p.fit)
101
+ .translate([d.mountOffsetX, 0, -p.plateH - 1]); // overcut past both plate faces
102
+ return plate.cut(socket);
103
+ },
104
+ },
105
+ },
106
+ // "assembly" is first so `measure`/`verify`/`render` (which default to the
107
+ // first view key) see only the two real, non-overlapping parts — `mount`'s
108
+ // socket never touches `body` (see `mountOffsetX`). "reference" is the
109
+ // ghost-overlay view, browsed by hand or with an explicit view argument.
110
+ views: { assembly: { label: "Assembly" }, reference: { label: "Reference overlay" } },
111
+ verify: {
112
+ process: "fdm-pla",
113
+ expect: {
114
+ body: {
115
+ holes: 0,
116
+ watertight: true,
117
+ bbox: "<=[30,20,15]",
118
+ // Deviation gate: at the defaults, body reproduces the scan's exact
119
+ // box dimensions, so all three read near-zero — thresholds below
120
+ // leave real margin rather than sitting on the exact values.
121
+ refXorVolume: "<=5mm3",
122
+ refVolumeDeltaPct: "<=1",
123
+ refBboxDelta: "<=[0.2,0.2,0.2]",
124
+ },
125
+ mount: { holes: 1, watertight: true, bbox: "<=[40,25,10]" },
126
+ // Checked against the default ("assembly") view only — `body` and
127
+ // `mount` are real, non-overlapping parts by construction (see
128
+ // `mountOffsetX`). The "reference" view's ghost is deliberately
129
+ // coincident with `body` (that's the point of the overlay) and would
130
+ // always read overlaps > 0, which is why it isn't the default view.
131
+ _view: { overlaps: 0 },
132
+ },
133
+ },
134
+ };
@@ -0,0 +1,19 @@
1
+ // Node-side source mapping for part asset declarations (fonts + imports):
2
+ // framework resolvers use global fetch, which cannot read file: URLs in Node,
3
+ // so map those to bytes here before handing the decl down. Everything else
4
+ // (http(s) strings, bytes, thunks) passes through untouched.
5
+ import { readFileSync } from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ export function nodeAssetSources(decl) {
9
+ if (!decl) return decl;
10
+ const out = {};
11
+ for (const [name, src] of Object.entries(decl)) {
12
+ const u = src instanceof URL ? src : typeof src === "string" && src.startsWith("file:") ? new URL(src) : null;
13
+ if (u?.protocol === "file:") {
14
+ const b = readFileSync(fileURLToPath(u));
15
+ out[name] = b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength);
16
+ } else out[name] = src;
17
+ }
18
+ return out;
19
+ }
@@ -4,12 +4,24 @@
4
4
  import Module from "manifold-3d";
5
5
  import { createManifoldKernel } from "../framework/geometry/manifold-backend.js";
6
6
  import { resolveFonts } from "../framework/fonts.js";
7
+ import { ensureImports } from "../framework/imports.js";
8
+ import { nodeAssetSources } from "./assets.js";
9
+ import { tessellateStepAssets } from "./step-mesh.js";
7
10
 
8
- export async function bootManifoldKernel({ quality = "preview", fonts } = {}) {
11
+ export async function bootManifoldKernel({ quality = "preview", fonts, imports, importMeshes } = {}) {
9
12
  const wasm = await Module();
10
13
  wasm.setup();
11
14
  const kernel = createManifoldKernel(wasm, { quality });
12
15
  if (fonts) { const opentype = (await import("opentype.js")).default;
13
- for (const [name, buf] of await resolveFonts(fonts)) kernel._fonts.set(name, opentype.parse(buf)); }
16
+ for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, opentype.parse(buf)); }
17
+ if (imports) {
18
+ const decl = nodeAssetSources(imports);
19
+ const { resolveImports } = await import("../framework/imports.js");
20
+ const resolved = await resolveImports(decl);
21
+ const stepEntries = [...resolved].filter(([, a]) => a.format === "step")
22
+ .map(([name, a]) => ({ name, bytes: a.bytes, digest: a.digest }));
23
+ const meshes = importMeshes ?? (stepEntries.length ? await tessellateStepAssets(stepEntries) : null);
24
+ await ensureImports(kernel, decl, meshes);
25
+ }
14
26
  return kernel;
15
27
  }
@@ -6,8 +6,10 @@ import path from "path";
6
6
  import fs from "fs";
7
7
  import { createOcctKernel } from "../framework/geometry/occt-backend.js";
8
8
  import { resolveFonts } from "../framework/fonts.js";
9
+ import { ensureImports } from "../framework/imports.js";
10
+ import { nodeAssetSources } from "./assets.js";
9
11
 
10
- export async function bootOcctKernel({ fonts } = {}) {
12
+ export async function bootOcctKernel({ fonts, imports, importMeshes } = {}) {
11
13
  const require = createRequire(import.meta.url);
12
14
  globalThis.require = globalThis.require ?? require;
13
15
  globalThis.__dirname = globalThis.__dirname ?? path.dirname(fileURLToPath(import.meta.url));
@@ -17,6 +19,7 @@ export async function bootOcctKernel({ fonts } = {}) {
17
19
  replicad.setOC(OC);
18
20
  const kernel = createOcctKernel(replicad);
19
21
  if (fonts) { const opentype = (await import("opentype.js")).default;
20
- for (const [name, buf] of await resolveFonts(fonts)) kernel._fonts.set(name, opentype.parse(buf)); }
22
+ for (const [name, buf] of await resolveFonts(nodeAssetSources(fonts))) kernel._fonts.set(name, opentype.parse(buf)); }
23
+ if (imports) await ensureImports(kernel, nodeAssetSources(imports), importMeshes ?? null);
21
24
  return kernel;
22
25
  }
@@ -0,0 +1,15 @@
1
+ // src/testing/step-mesh-thread.js — runs INSIDE the worker_thread only.
2
+ import { parentPort, workerData } from "node:worker_threads";
3
+ import { bootOcctKernel } from "./occt.js";
4
+
5
+ const kernel = await bootOcctKernel();
6
+ const out = [];
7
+ const transfer = [];
8
+ for (const { name, bytes, digest } of workerData) {
9
+ await kernel._registerImport({ name, digest, step: bytes });
10
+ const { positions, indices } = kernel.import(name).toIndexedMesh({ quality: "print" });
11
+ out.push({ name, digest, positions, indices });
12
+ transfer.push(positions.buffer, indices.buffer);
13
+ }
14
+ parentPort.postMessage(out, transfer);
15
+ process.exit(0);
@@ -0,0 +1,17 @@
1
+ // STEP → triangle mesh for the Node crossover (Manifold part importing STEP).
2
+ // OCCT boots in a worker_thread — a separate isolate is a separate WASM world,
3
+ // so the "never both kernels in one process" invariant holds by construction.
4
+ import { Worker } from "node:worker_threads";
5
+
6
+ export function tessellateStepAssets(entries) {
7
+ return new Promise((resolve, reject) => {
8
+ const w = new Worker(new URL("./step-mesh-thread.js", import.meta.url),
9
+ { workerData: entries.map(({ name, bytes, digest }) => ({ name, bytes, digest })) });
10
+ w.once("message", (out) => {
11
+ resolve(new Map(out.map((m) => [m.name, { digest: m.digest, positions: m.positions, indices: m.indices }])));
12
+ w.terminate();
13
+ });
14
+ w.once("error", reject);
15
+ w.once("exit", (code) => { if (code !== 0) reject(new Error(`step tessellation thread exited ${code}`)); });
16
+ });
17
+ }
package/types/kernel.d.ts CHANGED
@@ -269,6 +269,12 @@ export interface Solid {
269
269
  * Closed (no open face) hollows are not supported.
270
270
  */
271
271
  shell(o: { t: number; open: FaceSelector }): Solid;
272
+ /**
273
+ * Morphological close-then-open with a ball of radius `r`: rounds EVERY edge
274
+ * (convex and concave). Implemented natively on both backends — never routes,
275
+ * never throws `KernelCapabilityError`. `roundAll(0)` is the identity.
276
+ */
277
+ roundAll(r: number | { r: number }): Solid;
272
278
  /** Through-hole count (Manifold only). */
273
279
  genus?(): number;
274
280
  /** No geometry at all (Manifold only). */
@@ -471,6 +477,12 @@ export interface GeometryKernel {
471
477
  hullChain(inputs: HullInput[]): Shape2D;
472
478
  /** STEP bytes — OCCT only (Manifold throws `KernelCapabilityError`). */
473
479
  toSTEP(named: Array<{ name: string; solid: Solid }>): Promise<ArrayBuffer>;
480
+ /**
481
+ * Imported geometry declared in the part's `imports` field, registered
482
+ * pre-build by the framework via the underscore-prefixed `_registerImport`
483
+ * side-channel (not a part author's calling surface).
484
+ */
485
+ import(name: string): Solid;
474
486
 
475
487
  // Backend-optional: the sub-part cache brackets and WASM lifetime hooks. Every
476
488
  // framework caller reaches these through `?.`, so a third-party backend may
package/types/part.d.ts CHANGED
@@ -300,6 +300,14 @@ export interface SubPartDefinition<P = ResolvedParams, D = Derived> {
300
300
  enabled?: (p: P) => unknown;
301
301
  /** `false` = reference/preview-only: shown in the viewer, never exported. */
302
302
  exportable?: boolean;
303
+ /**
304
+ * Name of a declared `imports` entry this sub-part is held to. When set,
305
+ * `measure()` computes a `deviation` fact (symmetric-difference volume,
306
+ * volume delta %, bbox-corner drift) against that import's posed solid, and
307
+ * `verify.expect.<subpart>` may use the `refXorVolume` / `refVolumeDeltaPct`
308
+ * / `refBboxDelta` gate metrics.
309
+ */
310
+ reference?: string;
303
311
  /** Viewer-only override — `color` is `0xRRGGBB`, `opacity` is 0..1. */
304
312
  display?: { color?: number; opacity?: number };
305
313
  /** Filename / object name on export; defaults to the key. */
@@ -362,6 +370,12 @@ export interface SubPartExpectations {
362
370
  boundsMin?: Expectation;
363
371
  boundsMax?: Expectation;
364
372
  minWall?: Expectation;
373
+ /** Symmetric-difference volume vs. the sub-part's declared `reference` import. */
374
+ refXorVolume?: Expectation;
375
+ /** Percent volume delta vs. the sub-part's declared `reference` import. */
376
+ refVolumeDeltaPct?: Expectation;
377
+ /** Bounding-box corner drift `[dx, dy, dz]` vs. the sub-part's declared `reference` import. */
378
+ refBboxDelta?: Expectation;
365
379
  }
366
380
 
367
381
  /**