partforge 0.34.0 → 0.36.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/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -705,6 +705,24 @@ Copy `demo.html` and change the title, the panel heading, and the `<script src>`
|
|
|
705
705
|
workers are spawned from your one worker entry (`name` = `"manifold"` for preview/STL/3MF,
|
|
706
706
|
`"occt"` for STEP — handled for you).
|
|
707
707
|
|
|
708
|
+
**Headless export (the mount handle).** The `#download*` buttons above are the built-in,
|
|
709
|
+
view-bound export UI. An embedder that wants its own export UI (e.g. a "pick which parts,
|
|
710
|
+
pick a format" modal) can skip those buttons and drive export off the handle `mount()`
|
|
711
|
+
returns instead:
|
|
712
|
+
|
|
713
|
+
- `runtime.listExportableParts() → [{ name, label }]` — every exportable sub-part
|
|
714
|
+
(excludes any `exportable: false` part, respects each part's `enabled(params)`),
|
|
715
|
+
**independent of the active view**. Use it to populate an export checklist.
|
|
716
|
+
- `runtime.exportParts({ parts, format, quality?, onProgress }) → Promise<void>` — build
|
|
717
|
+
the given `parts` (sub-part names) in `format` (`"stl" | "step" | "3mf"`), streaming
|
|
718
|
+
phase strings to `onProgress(phase)`. Resolves once the file is written (handed to your
|
|
719
|
+
`onDownload` sink, or downloaded directly if you don't supply one); rejects on
|
|
720
|
+
build/export failure or an empty selection. Placement uses the current
|
|
721
|
+
view. STEP is routed to OCCT automatically.
|
|
722
|
+
|
|
723
|
+
Pass `onDownload({ data, filename, mime })` to `mount()` to receive the exported bytes
|
|
724
|
+
yourself (e.g. to download from a different origin) instead of partforge's own DOM download.
|
|
725
|
+
|
|
708
726
|
**The markup convention (`demo.html` is the canonical copy-me page):** `<body>` carries
|
|
709
727
|
`class="pf-shell"`, the flex row that lays the viewer column next to the rail. `#app`
|
|
710
728
|
(`class="pf-stage"`) *is* that viewer column, and now contains the floating chrome
|
package/package.json
CHANGED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// src/framework/export-controller.js
|
|
2
|
+
// Owns the jobId correlation for headless exportParts(): matches worker
|
|
3
|
+
// replies (progress/download/error) back to the Promise that started them.
|
|
4
|
+
// Pure — no DOM, no worker; `send` and the sink are injected.
|
|
5
|
+
import { triggerDownload, downloadParts } from "./download.js";
|
|
6
|
+
|
|
7
|
+
export function createExportController({ send, currentView, title, defaultBackend = () => "manifold", currentParams = () => ({}) }) {
|
|
8
|
+
const pending = new Map(); // jobId -> { resolve, reject, onProgress }
|
|
9
|
+
let nextId = 1;
|
|
10
|
+
|
|
11
|
+
function exportParts({ parts, format, quality = "print", onProgress } = {}) {
|
|
12
|
+
const jobId = nextId++;
|
|
13
|
+
const type = `export-${format}`;
|
|
14
|
+
const backend = format === "step" ? "occt" : defaultBackend();
|
|
15
|
+
return new Promise((resolve, reject) => {
|
|
16
|
+
pending.set(jobId, { resolve, reject, onProgress });
|
|
17
|
+
send({ type, jobId, parts, view: currentView(), params: currentParams(), name: title(), quality }, backend);
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Returns true iff this message belonged to a pending export (so the caller
|
|
22
|
+
// can skip legacy handling). `sink` is partforge's onDownload.
|
|
23
|
+
function handleMessage(m, sink) {
|
|
24
|
+
const entry = m && m.jobId != null ? pending.get(m.jobId) : undefined;
|
|
25
|
+
if (!entry) return false;
|
|
26
|
+
if (m.type === "progress") { entry.onProgress?.(m.phase); return true; }
|
|
27
|
+
if (m.type === "download") {
|
|
28
|
+
pending.delete(m.jobId);
|
|
29
|
+
triggerDownload(m.data, m.filename, m.mime, sink);
|
|
30
|
+
entry.resolve();
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
if (m.type === "download-parts") {
|
|
34
|
+
pending.delete(m.jobId);
|
|
35
|
+
const zipName = `${title() ?? "parts"}.zip`.toLowerCase().replace(/\s+/g, "-");
|
|
36
|
+
downloadParts(m, zipName, sink);
|
|
37
|
+
entry.resolve();
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
if (m.type === "error") { pending.delete(m.jobId); entry.reject(new Error(m.message)); return true; }
|
|
41
|
+
if (m.type === "needs-occt") { pending.delete(m.jobId); entry.reject(new Error("needs OCCT backend")); return true; }
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Reject every in-flight export and clear the map — for teardown / worker
|
|
46
|
+
// death, where worker replies will never arrive to settle these Promises.
|
|
47
|
+
function dispose(reason) {
|
|
48
|
+
const err = new Error(reason ?? "export cancelled");
|
|
49
|
+
for (const entry of pending.values()) entry.reject(err);
|
|
50
|
+
pending.clear();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { exportParts, handleMessage, dispose };
|
|
54
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Pure selection helpers for headless export. No kernel, no DOM.
|
|
2
|
+
|
|
3
|
+
export function partLabel(part, name) {
|
|
4
|
+
return part.parts[name]?.label ?? name;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
// The union of sub-parts eligible for export, independent of the active view:
|
|
8
|
+
// exportable (not exportable:false) AND enabled under the current params.
|
|
9
|
+
export function exportablePartNames(part, params) {
|
|
10
|
+
return Object.keys(part.parts).filter((name) => {
|
|
11
|
+
const sp = part.parts[name];
|
|
12
|
+
if (sp.exportable === false) return false;
|
|
13
|
+
return sp.enabled ? !!sp.enabled(params) : true;
|
|
14
|
+
});
|
|
15
|
+
}
|
|
@@ -32,44 +32,9 @@ import { composePose, transformPositions } from "./pose.js";
|
|
|
32
32
|
import { meshToStl } from "./mesh-stl.js";
|
|
33
33
|
const MESH = { preview: { tolerance: 0.1, angularTolerance: 0.5 }, print: { tolerance: 0.01, angularTolerance: 0.1 } };
|
|
34
34
|
|
|
35
|
-
// Run replicad's `exportSTEP` (via `runExport`) and return the STEP bytes as a
|
|
36
|
-
// standalone ArrayBuffer WITHOUT touching a Blob — the sandbox worker on
|
|
37
|
-
// Safari/Firefox cannot read a Blob. replicad writes the STEP text to OCCT's
|
|
38
|
-
// virtual FS and reads it back with oc.FS.readFile; we wrap that read to capture
|
|
39
|
-
// the bytes, then hand them straight back. Two Safari-specific hazards, both
|
|
40
|
-
// handled here:
|
|
41
|
-
// * The captured Uint8Array is a view into the WASM heap, so we copy it
|
|
42
|
-
// immediately (.slice()) — the post-write cleanup can move/free the heap.
|
|
43
|
-
// * On Safari's OCCT build, that cleanup THROWS a destructor-signature
|
|
44
|
-
// mismatch AFTER the file is fully written and read ("...Write Done", then a
|
|
45
|
-
// RuntimeError: rawDestructor). The bytes are already captured, so the export
|
|
46
|
-
// succeeded — swallow the cleanup crash and return them. Only rethrow if
|
|
47
|
-
// nothing was captured (a genuine export failure).
|
|
48
|
-
// Exported for direct unit testing of the crash-tolerance (the fatal path only
|
|
49
|
-
// reproduces in real Safari).
|
|
50
|
-
export function stepBytesViaFsCapture(oc, runExport) {
|
|
51
|
-
const realRead = oc.FS.readFile; // restore this exact ref (no bind accumulation across exports)
|
|
52
|
-
let captured = null;
|
|
53
|
-
oc.FS.readFile = (path, ...rest) => {
|
|
54
|
-
const bytes = realRead.call(oc.FS, path, ...rest);
|
|
55
|
-
if (typeof path === "string" && path.toLowerCase().endsWith(".step")) captured = bytes.slice();
|
|
56
|
-
return bytes;
|
|
57
|
-
};
|
|
58
|
-
try {
|
|
59
|
-
runExport();
|
|
60
|
-
} catch (e) {
|
|
61
|
-
if (!captured) throw e; // failed before producing any STEP bytes — a real error
|
|
62
|
-
// else: post-write cleanup crashed after the file was captured; ignore it.
|
|
63
|
-
} finally {
|
|
64
|
-
oc.FS.readFile = realRead;
|
|
65
|
-
}
|
|
66
|
-
if (!captured || captured.byteLength === 0) throw new Error("STEP export produced no bytes");
|
|
67
|
-
return captured.buffer;
|
|
68
|
-
}
|
|
69
|
-
|
|
70
35
|
export function createOcctKernel(replicad) {
|
|
71
36
|
const { makeCylinder, makeBox, makeCircle, makeHelix, assembleWire, genericSweep,
|
|
72
|
-
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane
|
|
37
|
+
makeCompound, loft, draw, exportSTEP, measureVolume, makeSphere, makeLine, Plane } = replicad;
|
|
73
38
|
|
|
74
39
|
// Fillet/chamfer/shell failure recovery (skip-on-failure, chamfer binary search) —
|
|
75
40
|
// see occt-repair.js for the policies and why they differ per op.
|
|
@@ -484,9 +449,7 @@ export function createOcctKernel(replicad) {
|
|
|
484
449
|
});
|
|
485
450
|
},
|
|
486
451
|
shape2d,
|
|
487
|
-
toSTEP: (named) =>
|
|
488
|
-
Promise.resolve(stepBytesViaFsCapture(getOC(), () =>
|
|
489
|
-
exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))))),
|
|
452
|
+
toSTEP: (named) => exportSTEP(named.map(({ name, solid }) => ({ name, shape: solid._mat()._s }))).arrayBuffer(),
|
|
490
453
|
beginSubPart: (name) => cache.begin(name),
|
|
491
454
|
endSubPart: () => cache.end(),
|
|
492
455
|
sweepCache: () => cache.sweep(),
|
package/src/framework/jobs.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { meshTo3MF } from "./geometry/threemf.js";
|
|
2
|
+
import { exportablePartNames } from "./export-select.js";
|
|
2
3
|
import { resolveDerived } from "./derive.js";
|
|
3
4
|
import { resolveFonts } from "./fonts.js";
|
|
4
5
|
import { measure } from "../testing/measure.js";
|
|
@@ -58,7 +59,7 @@ const bufferOf = (data) => (ArrayBuffer.isView(data) ? data.buffer : data);
|
|
|
58
59
|
|
|
59
60
|
export async function handle(kernel, part, msg, post, opts = {}) {
|
|
60
61
|
const isStale = opts.isStale ?? (() => false);
|
|
61
|
-
const onProgress = (phase) => post({ type: "progress", phase });
|
|
62
|
+
const onProgress = (phase) => post({ type: "progress", phase, jobId: msg.jobId });
|
|
62
63
|
const label = (name) => part.parts[name].label ?? name;
|
|
63
64
|
const exportName = (name) => part.parts[name].export?.name ?? name;
|
|
64
65
|
|
|
@@ -76,6 +77,12 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
76
77
|
const { p, d } = resolveParams(part, msg.params);
|
|
77
78
|
// Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
|
|
78
79
|
const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
|
|
80
|
+
// Explicit selection (headless exportParts) overrides view-derived selection.
|
|
81
|
+
const selected = () =>
|
|
82
|
+
msg.parts
|
|
83
|
+
? exportablePartNames(part, p).filter((name) => msg.parts.includes(name))
|
|
84
|
+
: exportSubParts(part, msg.view, p);
|
|
85
|
+
const fileBase = msg.name ?? msg.view; // STEP/3MF single-file name base
|
|
79
86
|
|
|
80
87
|
if (msg.type === "generate") {
|
|
81
88
|
const t0 = Date.now();
|
|
@@ -105,29 +112,36 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
105
112
|
[m.positions.buffer, m.normals?.buffer, m.indices?.buffer, m.edges?.buffer, m.featureIds?.buffer].filter(Boolean));
|
|
106
113
|
post({ type: "meshes", meshes, ms: Date.now() - t0, cache: kernel.cacheStats?.() }, transfer);
|
|
107
114
|
} else if (msg.type === "export-stl") {
|
|
115
|
+
const names = selected();
|
|
116
|
+
if (names.length === 0) throw new Error("no exportable parts selected");
|
|
108
117
|
const out = [];
|
|
109
|
-
for (const name of
|
|
118
|
+
for (const name of names) {
|
|
110
119
|
onProgress(`building ${label(name)}`);
|
|
111
|
-
out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: "print" }) });
|
|
120
|
+
out.push({ name: exportName(name), data: await posed(name, "export", onProgress).toSTL({ quality: msg.quality ?? "print" }) });
|
|
112
121
|
}
|
|
113
|
-
post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out
|
|
122
|
+
post({ type: "download-parts", ext: "stl", mime: "model/stl", parts: out, jobId: msg.jobId },
|
|
123
|
+
out.map((pp) => bufferOf(pp.data)));
|
|
114
124
|
} else if (msg.type === "export-step") {
|
|
115
|
-
const
|
|
125
|
+
const names = selected();
|
|
126
|
+
if (names.length === 0) throw new Error("no exportable parts selected");
|
|
127
|
+
const solids = names.map((name) => {
|
|
116
128
|
onProgress(`building ${label(name)}`);
|
|
117
129
|
return { name: exportName(name), solid: posed(name, "export", onProgress) };
|
|
118
130
|
});
|
|
119
131
|
onProgress("writing STEP file");
|
|
120
132
|
const data = await kernel.toSTEP(solids);
|
|
121
|
-
post({ type: "download", data, filename: `${
|
|
133
|
+
post({ type: "download", data, filename: `${fileBase}.step`, mime: "application/step", jobId: msg.jobId }, [bufferOf(data)]);
|
|
122
134
|
} else if (msg.type === "export-3mf") {
|
|
123
|
-
const
|
|
135
|
+
const names = selected();
|
|
136
|
+
if (names.length === 0) throw new Error("no exportable parts selected");
|
|
137
|
+
const meshes = names.map((name) => {
|
|
124
138
|
onProgress(`building ${label(name)}`);
|
|
125
139
|
const { positions, indices } = posed(name, "export", onProgress).toIndexedMesh();
|
|
126
140
|
return { name: exportName(name), positions, indices };
|
|
127
141
|
});
|
|
128
142
|
onProgress("writing 3MF file");
|
|
129
143
|
const data = meshTo3MF(meshes);
|
|
130
|
-
post({ type: "download", data, filename: `${
|
|
144
|
+
post({ type: "download", data, filename: `${fileBase}.3mf`, mime: "model/3mf", jobId: msg.jobId }, [bufferOf(data)]);
|
|
131
145
|
} else if (msg.type === "inspect") {
|
|
132
146
|
// Full geometric oracle for the current view: solid facts (volume/genus/
|
|
133
147
|
// watertight), mesh facts, overlaps, and gap distances, plus the part's
|
|
@@ -142,8 +156,8 @@ export async function handle(kernel, part, msg, post, opts = {}) {
|
|
|
142
156
|
post({ type: "report", ...report });
|
|
143
157
|
}
|
|
144
158
|
} catch (err) {
|
|
145
|
-
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt" });
|
|
146
|
-
else post({ type: "error", message: String(err?.message || err) });
|
|
159
|
+
if (err?.code === "NEEDS_OCCT") post({ type: "needs-occt", jobId: msg.jobId });
|
|
160
|
+
else post({ type: "error", message: String(err?.message || err), jobId: msg.jobId });
|
|
147
161
|
} finally {
|
|
148
162
|
kernel.cleanup?.();
|
|
149
163
|
}
|
package/src/framework/mount.js
CHANGED
|
@@ -20,11 +20,18 @@ import { createStatusUi } from "./status-ui.js";
|
|
|
20
20
|
import { createViewTabs } from "./view-tabs.js";
|
|
21
21
|
import { attachPickToggle, attachHoverLabels, attachPicker, formatSelection } from "./selection/index.js";
|
|
22
22
|
import { createPickRequestClient } from "./pick-request/index.js";
|
|
23
|
+
import { exportablePartNames, partLabel } from "./export-select.js";
|
|
24
|
+
import { createExportController } from "./export-controller.js";
|
|
23
25
|
|
|
24
26
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
25
27
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
26
|
-
export function makeHandle({ ready, dispose, viewer, setParams }) {
|
|
27
|
-
return {
|
|
28
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts }) {
|
|
29
|
+
return {
|
|
30
|
+
ready, dispose, setParams,
|
|
31
|
+
captureViews: (viewNames) => viewer.captureCanonicalViews(viewNames),
|
|
32
|
+
listExportableParts,
|
|
33
|
+
exportParts,
|
|
34
|
+
};
|
|
28
35
|
}
|
|
29
36
|
|
|
30
37
|
function createCleanupStack() {
|
|
@@ -56,10 +63,16 @@ function createCleanupStack() {
|
|
|
56
63
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
57
64
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
58
65
|
//
|
|
59
|
-
// Embedding contract (0.
|
|
60
|
-
// const runtime = mount(part, { createWorker, elements, onBuild, onPick });
|
|
66
|
+
// Embedding contract (0.36.0):
|
|
67
|
+
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload });
|
|
61
68
|
// await runtime.ready; // first successful build of the default view
|
|
62
69
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
70
|
+
// runtime.listExportableParts(); // [{ name, label }] — every exportable sub-part,
|
|
71
|
+
// // independent of the active view (for an embedder-drawn export UI)
|
|
72
|
+
// await runtime.exportParts({ parts: ["base"], format: "stl", onProgress });
|
|
73
|
+
// // headless export of a chosen subset; resolves when the file is
|
|
74
|
+
// // written (handed to your onDownload sink, or downloaded directly
|
|
75
|
+
// // if you don't supply one), rejects on failure
|
|
63
76
|
// runtime.dispose(); // full teardown
|
|
64
77
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
65
78
|
// those are repaired in the viewer and produce no build at all.
|
|
@@ -279,6 +292,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
279
292
|
|
|
280
293
|
// --- shared message handler ------------------------------------------------
|
|
281
294
|
function onWorkerMessage({ data }) {
|
|
295
|
+
// Headless exportParts() correlation: consume its own replies first.
|
|
296
|
+
if (exportCtl.handleMessage(data, onDownload)) return;
|
|
282
297
|
switch (data.type) {
|
|
283
298
|
case "ready":
|
|
284
299
|
loop.ready(); // auto-build the default view (keeps the busy spinner up)
|
|
@@ -338,6 +353,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
338
353
|
const service = createGeometryService({ createWorker, onMessage: onWorkerMessage });
|
|
339
354
|
cleanup.defer(() => service.terminate());
|
|
340
355
|
|
|
356
|
+
const exportCtl = createExportController({
|
|
357
|
+
send: (msg, backend) => service.send(msg, backend),
|
|
358
|
+
currentView: () => view(),
|
|
359
|
+
title: () => part.meta?.title ?? "parts",
|
|
360
|
+
defaultBackend: () => backendFor(),
|
|
361
|
+
currentParams: () => params,
|
|
362
|
+
});
|
|
363
|
+
cleanup.defer(() => exportCtl.dispose("viewer disposed"));
|
|
364
|
+
|
|
341
365
|
const panel = buildControls(els.controls, part.parameters, params, onParamChange);
|
|
342
366
|
cleanup.defer(() => panel.dispose());
|
|
343
367
|
const updateRelevance = () => panel.applyRelevance(relevantParamKeys(part, view(), params));
|
|
@@ -417,7 +441,12 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
|
|
|
417
441
|
cleanup.dispose();
|
|
418
442
|
}
|
|
419
443
|
|
|
420
|
-
return makeHandle({
|
|
444
|
+
return makeHandle({
|
|
445
|
+
ready, dispose, viewer, setParams,
|
|
446
|
+
listExportableParts: () =>
|
|
447
|
+
exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
|
|
448
|
+
exportParts: (opts) => exportCtl.exportParts(opts),
|
|
449
|
+
});
|
|
421
450
|
} catch (error) {
|
|
422
451
|
try {
|
|
423
452
|
cleanup.dispose();
|