partforge 0.63.0 → 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 +135 -0
- package/docs/ERROR-PATTERNS.md +29 -1
- package/docs/KERNEL-CONTRACT.md +15 -1
- 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 +2 -1
- package/src/framework/geometry/manifold-backend.js +41 -0
- package/src/framework/geometry/mesh-repair.js +87 -0
- package/src/framework/geometry/occt-backend.js +33 -1
- 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 +6 -0
- package/types/part.d.ts +14 -0
package/src/framework/mount.js
CHANGED
|
@@ -37,6 +37,17 @@ const NOOP_TOOLTIP_BINDING = { sync: () => {}, hide: () => {}, detach: () => {}
|
|
|
37
37
|
// Same no-op-default stance as attachTooltips/setHostPane below, for a
|
|
38
38
|
// makeHandle caller (or a direct test) that doesn't wire measure mode.
|
|
39
39
|
const NOOP_MEASURE = { isEnabled: () => false, setEnabled: () => {}, clearPins: () => {}, pinCount: () => 0 };
|
|
40
|
+
// The STEP-on-Manifold import crossover's broken-state message (a second
|
|
41
|
+
// needs-import-mesh after the mesh is already primed — see the "needs-import-mesh"
|
|
42
|
+
// case below). One shared string so the status line, onBuild, and the ready
|
|
43
|
+
// rejection can never drift apart.
|
|
44
|
+
const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy the import — see console";
|
|
45
|
+
// The crossover's OTHER failure mode: the tessellate-imports request itself
|
|
46
|
+
// throws (malformed STEP, etc.) rather than delivering a mesh to prime. Distinct
|
|
47
|
+
// from IMPORT_MESH_BROKEN_MESSAGE above (that one names a digest mismatch AFTER
|
|
48
|
+
// a successful tessellation) — this one names the tessellation failure and
|
|
49
|
+
// carries the worker's own error text. See the correlated "error" case below.
|
|
50
|
+
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
40
51
|
|
|
41
52
|
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure }) {
|
|
42
53
|
return {
|
|
@@ -261,6 +272,30 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
261
272
|
if (forcedBackend !== "occt" && forcedBackend !== "manifold") forcedBackend = null;
|
|
262
273
|
const backendPolicy = createBackendPolicy(part, { forced: forcedBackend });
|
|
263
274
|
const backendFor = () => backendPolicy.backendFor(params);
|
|
275
|
+
// STEP-on-Manifold import crossover (spec 2026-08-16): null → "requested" →
|
|
276
|
+
// "primed", tracking the one tessellate-imports round trip this mount instance
|
|
277
|
+
// ever needs (a worker-lifetime prime, per Task 8). One mount() call handles
|
|
278
|
+
// exactly one part for its whole lifetime — there is no rebind point to reset
|
|
279
|
+
// this at — so declaring it here beside backendPolicy is its only reset.
|
|
280
|
+
let importMeshState = null;
|
|
281
|
+
// jobId of the outstanding tessellate-imports request (mount-generated —
|
|
282
|
+
// jobs.js's tessellate-imports branch echoes it back on both its
|
|
283
|
+
// "import-meshes" reply and, via the shared catch, on a thrown "error").
|
|
284
|
+
// Lets the "error" case below tell a correlated tessellation failure apart
|
|
285
|
+
// from an unrelated build error sharing the same message type, so it can
|
|
286
|
+
// reset the latch instead of stranding it at "requested" forever.
|
|
287
|
+
// String-namespaced ("tess-N"), mirroring capture-build.js's "cap-N" —
|
|
288
|
+
// this request shares the OCCT worker's message space with
|
|
289
|
+
// export-controller's PLAIN NUMERIC jobIds (both start counting at 1), and
|
|
290
|
+
// exportCtl.handleMessage (called before mount's own switch, below) does a
|
|
291
|
+
// raw pending.get(m.jobId) before checking type. A bare numeric id here
|
|
292
|
+
// could collide with a pending STEP export's jobId, so the export
|
|
293
|
+
// controller would wrongly claim this reply — rejecting an unrelated
|
|
294
|
+
// export with the tessellation failure text AND leaving importMeshState
|
|
295
|
+
// stranded at "requested" (mount's own switch, which would have reset it,
|
|
296
|
+
// never runs). The string namespace makes that collision impossible.
|
|
297
|
+
let importTessellateJobId = null;
|
|
298
|
+
let importTessellateJobSeq = 0;
|
|
264
299
|
|
|
265
300
|
// ?debug shows the cache debug overlay; ?debug&nocache starts with caching off.
|
|
266
301
|
const qs = new URLSearchParams(location.search);
|
|
@@ -582,7 +617,64 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
582
617
|
loop.buildDone();
|
|
583
618
|
loop.kick();
|
|
584
619
|
break;
|
|
585
|
-
case "
|
|
620
|
+
case "needs-import-mesh":
|
|
621
|
+
// STEP import on the Manifold worker: an unprimed import threw
|
|
622
|
+
// NEEDS_IMPORT_MESH (Task 8). Settle this build the same way
|
|
623
|
+
// needs-occt does — buildDone() only, no kick() here: the retry can't
|
|
624
|
+
// succeed until priming completes, and kicking now would just repeat
|
|
625
|
+
// the same failure before the mesh exists. The "import-meshes" case
|
|
626
|
+
// below is what kicks, once the prime has actually landed.
|
|
627
|
+
loop.buildDone();
|
|
628
|
+
if (importMeshState === "primed") {
|
|
629
|
+
// Tessellation already delivered a mesh but Manifold still can't
|
|
630
|
+
// satisfy the import — the digest didn't match. A genuinely broken
|
|
631
|
+
// state, not a retry loop: surface it like any other build error
|
|
632
|
+
// rather than re-requesting tessellation forever.
|
|
633
|
+
ui.hideBusy();
|
|
634
|
+
refreshView();
|
|
635
|
+
ui.setStatus(`failed: ${IMPORT_MESH_BROKEN_MESSAGE}`, true);
|
|
636
|
+
onBuild?.({ status: "error", error: IMPORT_MESH_BROKEN_MESSAGE });
|
|
637
|
+
if (!readySettled) { readySettled = true; rejectReady(new Error(IMPORT_MESH_BROKEN_MESSAGE)); }
|
|
638
|
+
} else if (importMeshState !== "requested") {
|
|
639
|
+
importMeshState = "requested";
|
|
640
|
+
importTessellateJobId = `tess-${++importTessellateJobSeq}`;
|
|
641
|
+
service.send({ type: "tessellate-imports", jobId: importTessellateJobId }, "occt");
|
|
642
|
+
}
|
|
643
|
+
break;
|
|
644
|
+
case "import-meshes":
|
|
645
|
+
// The OCCT worker answered tessellate-imports: prime the Manifold
|
|
646
|
+
// worker's import cache (worker-lifetime, per Task 8) with transferable
|
|
647
|
+
// mesh buffers, then kick the loop — the needs-import-mesh case above
|
|
648
|
+
// already settled the failed build (buildDone), so this kick is the
|
|
649
|
+
// other half of the same buildDone()/kick() pairing needs-occt does in
|
|
650
|
+
// one step, split across the two crossover replies here.
|
|
651
|
+
importMeshState = "primed";
|
|
652
|
+
importTessellateJobId = null; // the request this jobId correlated is answered — nothing to match against it anymore
|
|
653
|
+
service.send({ type: "prime-imports", meshes: data.meshes }, "manifold",
|
|
654
|
+
Object.values(data.meshes).flatMap((m) => [m.positions.buffer, m.indices.buffer]));
|
|
655
|
+
loop.kick();
|
|
656
|
+
break;
|
|
657
|
+
case "error": {
|
|
658
|
+
// A correlated tessellate-imports failure (malformed STEP, etc.) must
|
|
659
|
+
// not fall through the generic handling below: that request was
|
|
660
|
+
// dispatched directly via service.send (see needs-import-mesh above),
|
|
661
|
+
// never through loop.send, so loop.buildDone() has no matching pending
|
|
662
|
+
// count to release for it — calling it here would mis-credit an
|
|
663
|
+
// unrelated in-flight generate job. Reset the latch instead of leaving
|
|
664
|
+
// it stuck at "requested" forever (which would silently swallow every
|
|
665
|
+
// later needs-import-mesh via the `!== "requested"` guard above) — the
|
|
666
|
+
// next kick (param/view change) retries tessellation fresh.
|
|
667
|
+
if (data.jobId != null && data.jobId === importTessellateJobId) {
|
|
668
|
+
importMeshState = null;
|
|
669
|
+
importTessellateJobId = null;
|
|
670
|
+
ui.hideBusy();
|
|
671
|
+
refreshView();
|
|
672
|
+
const tessellateMessage = importTessellateFailedMessage(data.message);
|
|
673
|
+
ui.setStatus(`failed: ${tessellateMessage}`, true);
|
|
674
|
+
onBuild?.({ status: "error", error: tessellateMessage });
|
|
675
|
+
if (!readySettled) { readySettled = true; rejectReady(new Error(tessellateMessage)); }
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
586
678
|
loop.buildDone();
|
|
587
679
|
ui.hideBusy();
|
|
588
680
|
// refreshView FIRST: its all-current branch clears the status line,
|
|
@@ -592,6 +684,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
592
684
|
onBuild?.({ status: "error", error: data.message });
|
|
593
685
|
if (!readySettled) { readySettled = true; rejectReady(new Error(data.message)); }
|
|
594
686
|
break;
|
|
687
|
+
}
|
|
595
688
|
}
|
|
596
689
|
}
|
|
597
690
|
|
|
@@ -15,7 +15,11 @@ const unionBounds = (list) => list.reduce(
|
|
|
15
15
|
// solid facts (volume/genus/emptiness) and mesh facts (bbox/area/triangles), plus
|
|
16
16
|
// the assembly overlap check plus pair gap distances (near misses are reported,
|
|
17
17
|
// never folded into `ok`). All solid facts are read BEFORE assemblyOverlaps,
|
|
18
|
-
// which frees the shared kernel's objects at its end.
|
|
18
|
+
// which frees the shared kernel's objects at its end. A sub-part that declares
|
|
19
|
+
// `reference: "<import name>"` also gets a `deviation` fact — the posed solid's
|
|
20
|
+
// symmetric-difference volume, volume delta %, and bbox-corner drift against
|
|
21
|
+
// that import — for the `ref*` gate metrics (verify-metrics.js); every other
|
|
22
|
+
// sub-part gets `deviation: null`.
|
|
19
23
|
// → { part, view, measuredMinWall, subparts[], aggregate, overlaps[], gaps[],
|
|
20
24
|
// nearMisses[], ok }
|
|
21
25
|
export function measure(kernel, part, view = Object.keys(part.views)[0], params = {}, opts = {}) {
|
|
@@ -43,16 +47,38 @@ export function measure(kernel, part, view = Object.keys(part.views)[0], params
|
|
|
43
47
|
// Resolved lazily and only when asked for: without min-wall, a single-sub-part
|
|
44
48
|
// view (no meshGaps) must still build no index at all.
|
|
45
49
|
const mw = opts.minWall ? minWall(mesh, { bvh: cachedBVH(mesh, bvhCache) }) : null;
|
|
50
|
+
const vol = solid.volume();
|
|
51
|
+
// Deviation-from-reference: only for a sub-part that declares `reference:
|
|
52
|
+
// "<import name>"` (Task 12 — the gate that holds a parametric rebuild to
|
|
53
|
+
// its imported reference), and only when this kernel can import at all (a
|
|
54
|
+
// bare/third-party kernel may lack `import`). Read here, alongside every
|
|
55
|
+
// other solid fact, so it is captured before assemblyOverlaps/cleanup below
|
|
56
|
+
// frees the shared kernel's objects.
|
|
57
|
+
const refName = part.parts[name]?.reference;
|
|
58
|
+
let deviation = null;
|
|
59
|
+
if (refName && typeof kernel.import === "function") {
|
|
60
|
+
const ref = kernel.import(refName);
|
|
61
|
+
const refVol = ref.volume();
|
|
62
|
+
const rb = ref.boundingBox();
|
|
63
|
+
const inter = solid.intersect(ref).volume();
|
|
64
|
+
deviation = {
|
|
65
|
+
ref: refName,
|
|
66
|
+
xorVolume: vol + refVol - 2 * inter, // symmetric difference, one boolean
|
|
67
|
+
volumeDeltaPct: refVol > 1e-9 ? (100 * Math.abs(vol - refVol)) / refVol : null,
|
|
68
|
+
bboxDelta: [0, 1, 2].map((i) => Math.max(Math.abs(b.min[i] - rb.min[i]), Math.abs(b.max[i] - rb.max[i]))),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
46
71
|
return {
|
|
47
72
|
name,
|
|
48
73
|
bbox: size(b),
|
|
49
74
|
bounds: { min: b.min, max: b.max },
|
|
50
75
|
centerOfMass: meshCentroid(mesh.positions, mesh.indices),
|
|
51
|
-
volume:
|
|
76
|
+
volume: vol,
|
|
52
77
|
surfaceArea: meshArea(mesh.positions, mesh.indices),
|
|
53
78
|
triangleCount: mesh.triangles,
|
|
54
79
|
watertight: typeof solid.isEmpty === "function" ? !solid.isEmpty() : null,
|
|
55
80
|
holes: typeof solid.genus === "function" ? solid.genus() : null,
|
|
81
|
+
deviation,
|
|
56
82
|
minWall: mw?.value ?? null,
|
|
57
83
|
minWallAt: mw?.location ?? null,
|
|
58
84
|
// Sampling accounting, so a report can tell a guaranteed minimum from an
|
|
@@ -46,6 +46,16 @@ export const SUBPART_METRICS = {
|
|
|
46
46
|
? `no reading from the ${sampled} of ${total} triangles sampled — not a clean bill of health; a thin spot may exist between samples`
|
|
47
47
|
: `sampled ${sampled} of ${total} triangles — an upper bound; a thinner spot may exist between samples`;
|
|
48
48
|
} },
|
|
49
|
+
// `s.deviation` (measure.js) exists only for a sub-part declaring `reference:
|
|
50
|
+
// "<import name>"`; on every other sub-part `extract` returns null, which
|
|
51
|
+
// `check()` already reports as status "skip" rather than a fail — the
|
|
52
|
+
// no-reference path needs nothing here.
|
|
53
|
+
refXorVolume: { kind: "gate", extract: (s) => s.deviation?.xorVolume ?? null,
|
|
54
|
+
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" },
|
|
55
|
+
refVolumeDeltaPct: { kind: "gate", extract: (s) => s.deviation?.volumeDeltaPct ?? null,
|
|
56
|
+
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" },
|
|
57
|
+
refBboxDelta: { kind: "gate", extract: (s) => s.deviation?.bboxDelta ?? null,
|
|
58
|
+
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" },
|
|
49
59
|
};
|
|
50
60
|
export const VIEW_METRICS = {
|
|
51
61
|
bbox: { kind: "gate", extract: (r) => r.aggregate.bbox,
|
package/src/framework/worker.js
CHANGED
|
@@ -45,6 +45,7 @@ export function runWorker(part) {
|
|
|
45
45
|
let epoch = 0; // bumped per incoming generate and per setPart
|
|
46
46
|
const queue = []; // { data, part, epoch } — jobs run against the part current at arrival
|
|
47
47
|
let pumping = false;
|
|
48
|
+
const importMeshes = new Map(); // name → {digest, positions, indices} — primed by the host for STEP-on-manifold
|
|
48
49
|
|
|
49
50
|
// Manifold is cheap to boot — bring it up eagerly and signal readiness.
|
|
50
51
|
if (backend === "manifold") {
|
|
@@ -98,7 +99,7 @@ export function runWorker(part) {
|
|
|
98
99
|
const kernel = await kernelFor(job.data);
|
|
99
100
|
// handle() declares each message's transferables (the big binary buffers).
|
|
100
101
|
const post = (m, transfer = []) => postMessage(m, transfer);
|
|
101
|
-
if (job.epoch === null) { await handle(kernel, job.part, job.data, post); continue; }
|
|
102
|
+
if (job.epoch === null) { await handle(kernel, job.part, job.data, post, { importMeshes }); continue; }
|
|
102
103
|
const isStale = () => job.epoch !== epoch;
|
|
103
104
|
// Post gate. The boundary check cannot catch a generate that goes stale during
|
|
104
105
|
// its FINAL sub-part — there is no boundary after it — nor a single-sub-part
|
|
@@ -107,7 +108,7 @@ export function runWorker(part) {
|
|
|
107
108
|
// contract simple: a `meshes` post is current as of the moment it is posted.
|
|
108
109
|
const gated = (m, transfer = []) =>
|
|
109
110
|
(m.type === "meshes" && isStale() ? post({ type: "superseded" }) : post(m, transfer));
|
|
110
|
-
await handle(kernel, job.part, job.data, gated, { isStale });
|
|
111
|
+
await handle(kernel, job.part, job.data, gated, { isStale, importMeshes });
|
|
111
112
|
} catch (err) {
|
|
112
113
|
// Same shape jobs.js posts for a failed build, so hosts need no new branch.
|
|
113
114
|
// Carry the job's jobId when it has one (capture/export are correlated by it):
|
|
@@ -123,6 +124,14 @@ export function runWorker(part) {
|
|
|
123
124
|
}
|
|
124
125
|
|
|
125
126
|
self.onmessage = (e) => {
|
|
127
|
+
// STEP-on-Manifold crossover: the host primes this worker's importMeshes before
|
|
128
|
+
// (or interleaved with) a generate, so a build's k.import(name) that needs
|
|
129
|
+
// pre-tessellated triangles finds them without touching the kernel — no queueing,
|
|
130
|
+
// no epoch bump, answered on the worker's own turn like the lint intercept below.
|
|
131
|
+
if (e.data?.type === "prime-imports") {
|
|
132
|
+
for (const [name, m] of Object.entries(e.data.meshes)) importMeshes.set(name, m);
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
126
135
|
// Lint is geometry-free by construction, so answer it before touching — or
|
|
127
136
|
// booting — a kernel. handle() in jobs.js takes an already-booted kernel, and
|
|
128
137
|
// the pump awaits that boot, so routing lint through the queue would drag in
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
solid import-demo-scan
|
|
2
|
+
facet normal 0 0 -1
|
|
3
|
+
outer loop
|
|
4
|
+
vertex 0 0 0
|
|
5
|
+
vertex 20 14 0
|
|
6
|
+
vertex 20 0 0
|
|
7
|
+
endloop
|
|
8
|
+
endfacet
|
|
9
|
+
facet normal 0 0 -1
|
|
10
|
+
outer loop
|
|
11
|
+
vertex 0 0 0
|
|
12
|
+
vertex 0 14 0
|
|
13
|
+
vertex 20 14 0
|
|
14
|
+
endloop
|
|
15
|
+
endfacet
|
|
16
|
+
facet normal 0 0 1
|
|
17
|
+
outer loop
|
|
18
|
+
vertex 0 0 8
|
|
19
|
+
vertex 20 0 8
|
|
20
|
+
vertex 20 14 8
|
|
21
|
+
endloop
|
|
22
|
+
endfacet
|
|
23
|
+
facet normal 0 0 1
|
|
24
|
+
outer loop
|
|
25
|
+
vertex 0 0 8
|
|
26
|
+
vertex 20 14 8
|
|
27
|
+
vertex 0 14 8
|
|
28
|
+
endloop
|
|
29
|
+
endfacet
|
|
30
|
+
facet normal 0 -1 0
|
|
31
|
+
outer loop
|
|
32
|
+
vertex 0 0 0
|
|
33
|
+
vertex 20 0 0
|
|
34
|
+
vertex 20 0 8
|
|
35
|
+
endloop
|
|
36
|
+
endfacet
|
|
37
|
+
facet normal 0 -1 0
|
|
38
|
+
outer loop
|
|
39
|
+
vertex 0 0 0
|
|
40
|
+
vertex 20 0 8
|
|
41
|
+
vertex 0 0 8
|
|
42
|
+
endloop
|
|
43
|
+
endfacet
|
|
44
|
+
facet normal 0 1 0
|
|
45
|
+
outer loop
|
|
46
|
+
vertex 0 14 0
|
|
47
|
+
vertex 0 14 8
|
|
48
|
+
vertex 20 14 8
|
|
49
|
+
endloop
|
|
50
|
+
endfacet
|
|
51
|
+
facet normal 0 1 0
|
|
52
|
+
outer loop
|
|
53
|
+
vertex 0 14 0
|
|
54
|
+
vertex 20 14 8
|
|
55
|
+
vertex 20 14 0
|
|
56
|
+
endloop
|
|
57
|
+
endfacet
|
|
58
|
+
facet normal -1 0 0
|
|
59
|
+
outer loop
|
|
60
|
+
vertex 0 0 0
|
|
61
|
+
vertex 0 0 8
|
|
62
|
+
vertex 0 14 8
|
|
63
|
+
endloop
|
|
64
|
+
endfacet
|
|
65
|
+
facet normal -1 0 0
|
|
66
|
+
outer loop
|
|
67
|
+
vertex 0 0 0
|
|
68
|
+
vertex 0 14 8
|
|
69
|
+
vertex 0 14 0
|
|
70
|
+
endloop
|
|
71
|
+
endfacet
|
|
72
|
+
facet normal 1 0 0
|
|
73
|
+
outer loop
|
|
74
|
+
vertex 20 0 0
|
|
75
|
+
vertex 20 14 0
|
|
76
|
+
vertex 20 14 8
|
|
77
|
+
endloop
|
|
78
|
+
endfacet
|
|
79
|
+
facet normal 1 0 0
|
|
80
|
+
outer loop
|
|
81
|
+
vertex 20 0 0
|
|
82
|
+
vertex 20 14 8
|
|
83
|
+
vertex 20 0 8
|
|
84
|
+
endloop
|
|
85
|
+
endfacet
|
|
86
|
+
endsolid import-demo-scan
|
|
@@ -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
|
@@ -477,6 +477,12 @@ export interface GeometryKernel {
|
|
|
477
477
|
hullChain(inputs: HullInput[]): Shape2D;
|
|
478
478
|
/** STEP bytes — OCCT only (Manifold throws `KernelCapabilityError`). */
|
|
479
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;
|
|
480
486
|
|
|
481
487
|
// Backend-optional: the sub-part cache brackets and WASM lifetime hooks. Every
|
|
482
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
|
/**
|