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.
- package/bin/cli.js +2 -3
- package/docs/AUTHORING-PARTS.md +158 -0
- package/docs/ERROR-PATTERNS.md +35 -1
- package/docs/KERNEL-CONTRACT.md +38 -2
- package/package.json +1 -1
- package/src/app-import-demo.js +18 -0
- package/src/framework/app.css +8 -1
- package/src/framework/asset-resolve.js +71 -0
- package/src/framework/capture-build.js +12 -4
- package/src/framework/export-controller.js +12 -0
- package/src/framework/fonts.js +12 -30
- package/src/framework/geometry/kernel.js +4 -3
- package/src/framework/geometry/manifold-backend.js +50 -0
- package/src/framework/geometry/mesh-repair.js +87 -0
- package/src/framework/geometry/mesh-roundall.js +83 -0
- package/src/framework/geometry/occt-backend.js +42 -1
- package/src/framework/geometry/occt-roundall.js +94 -0
- package/src/framework/geometry/op-options.js +2 -0
- package/src/framework/geometry/stl-parse.js +45 -0
- package/src/framework/geometry/threemf-parse.js +87 -0
- package/src/framework/geometry-service.js +3 -1
- package/src/framework/imports.js +84 -0
- package/src/framework/jobs.js +20 -1
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-imports.js +115 -0
- package/src/framework/mount.js +94 -1
- package/src/framework/oracle/measure.js +28 -2
- package/src/framework/verify-metrics.js +10 -0
- package/src/framework/worker.js +11 -2
- package/src/import-demo-worker.js +3 -0
- package/src/parts/assets/import-demo-scan.stl +86 -0
- package/src/parts/import-demo.js +134 -0
- package/src/testing/assets.js +19 -0
- package/src/testing/manifold.js +14 -2
- package/src/testing/occt.js +5 -2
- package/src/testing/step-mesh-thread.js +15 -0
- package/src/testing/step-mesh.js +17 -0
- package/types/kernel.d.ts +12 -0
- 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
|
+
}
|
package/src/testing/manifold.js
CHANGED
|
@@ -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
|
}
|
package/src/testing/occt.js
CHANGED
|
@@ -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
|
/**
|