partforge 0.95.0 → 0.96.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.
@@ -1925,6 +1925,15 @@ returns instead:
1925
1925
  `onDownload` sink, or downloaded directly if you don't supply one); rejects on
1926
1926
  build/export failure or an empty selection. Placement uses the current
1927
1927
  view. STEP is routed to OCCT automatically.
1928
+ - `runtime.warmExportKernel() → Promise<boolean>` — pay OCCT's cold boot *before* an
1929
+ export needs it. Because STEP is pinned to OCCT and OCCT's ~11 MB WASM loads on its
1930
+ first job, a part whose preview ran on Manifold pays that whole boot inside its first
1931
+ STEP export — the user waits having just asked for a file, and a host with an export
1932
+ timeout can trip it. Call this when an export becomes likely (your download dialog
1933
+ opening) and the wait lands somewhere harmless instead. Best-effort: resolves `true`
1934
+ once the kernel is up, `false` on any failure or teardown, never rejects, and is a
1935
+ cheap no-op once warm. It costs a speculative ~11 MB download, so fire it on a real
1936
+ signal of intent rather than on mount.
1928
1937
 
1929
1938
  Pass `onDownload({ data, filename, mime })` to `mount()` to receive the exported bytes
1930
1939
  yourself (e.g. to download from a different origin) instead of partforge's own DOM download.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.95.0",
3
+ "version": "0.96.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -18,6 +18,11 @@ export function backendForFormat(format, defaultBackend) {
18
18
  export function createExportController({ send, currentView, title, defaultBackend = () => "manifold", currentParams = () => ({}) }) {
19
19
  const pending = new Map(); // jobId -> { resolve, reject, onProgress }
20
20
  let nextId = 1;
21
+ // Warm jobs are STRING-namespaced ("warm-N") for the reason mount.js spells
22
+ // out for tessellate-imports: this map is keyed by jobId alone and read before
23
+ // any type check, so a bare numeric id here could be claimed by a pending
24
+ // export (both counters start at 1) and settle the wrong Promise.
25
+ let nextWarmId = 1;
21
26
 
22
27
  function exportParts({ parts, format, quality = "print", onProgress } = {}) {
23
28
  const jobId = nextId++;
@@ -29,12 +34,31 @@ export function createExportController({ send, currentView, title, defaultBacken
29
34
  });
30
35
  }
31
36
 
37
+ // Pay a backend's cold boot on purpose, before anything needs it. STEP is
38
+ // pinned to OCCT (backendForFormat), whose ~11 MB WASM loads lazily on its
39
+ // first job — so for a Manifold-previewed part the STEP export IS that boot,
40
+ // and the user waits for it having just asked for a file. A host that knows an
41
+ // export is likely (its download dialog just opened) can spend that time
42
+ // earlier instead.
43
+ //
44
+ // Best-effort by contract: resolves true once the kernel is up, false on any
45
+ // failure or teardown, and NEVER rejects — a speculative warm must not become
46
+ // an unhandled rejection in a host that fired it and moved on.
47
+ function warmKernel() {
48
+ const jobId = `warm-${nextWarmId++}`;
49
+ return new Promise((resolve) => {
50
+ pending.set(jobId, { resolve: () => resolve(true), reject: () => resolve(false) });
51
+ send({ type: "warm-kernel", jobId }, backendForFormat("step", defaultBackend));
52
+ });
53
+ }
54
+
32
55
  // Returns true iff this message belonged to a pending export (so the caller
33
56
  // can skip legacy handling). `sink` is partforge's onDownload.
34
57
  function handleMessage(m, sink) {
35
58
  const entry = m && m.jobId != null ? pending.get(m.jobId) : undefined;
36
59
  if (!entry) return false;
37
60
  if (m.type === "progress") { entry.onProgress?.(m.phase); return true; }
61
+ if (m.type === "kernel-warm") { pending.delete(m.jobId); entry.resolve(); return true; }
38
62
  if (m.type === "download") {
39
63
  pending.delete(m.jobId);
40
64
  triggerDownload(m.data, m.filename, m.mime, sink);
@@ -73,5 +97,5 @@ export function createExportController({ send, currentView, title, defaultBacken
73
97
  pending.clear();
74
98
  }
75
99
 
76
- return { exportParts, handleMessage, dispose };
100
+ return { exportParts, warmKernel, handleMessage, dispose };
77
101
  }
@@ -338,6 +338,13 @@ export async function handle(kernel, part, msg, post, opts = {}) {
338
338
  onProgress("writing 3MF file");
339
339
  const data = meshTo3MF(meshes);
340
340
  post({ type: "download", data, filename: `${fileBase}.3mf`, mime: "model/3mf", jobId: msg.jobId }, [bufferOf(data)]);
341
+ } else if (msg.type === "warm-kernel") {
342
+ // Deliberately empty. worker.js awaits kernelFor() before calling handle(),
343
+ // so REACHING this branch is the whole result: the backend this job was
344
+ // routed to now has a live kernel. It exists so a host can pay OCCT's cold
345
+ // ~11 MB boot at a moment of its own choosing — when the export dialog
346
+ // opens, say — instead of inside the STEP export the user just asked for.
347
+ post({ type: "kernel-warm", jobId: msg.jobId });
341
348
  } else if (msg.type === "tessellate-imports") {
342
349
  // OCCT-worker service job for the STEP-on-Manifold crossover: answer with
343
350
  // print-quality triangle meshes for every STEP import, transferable.
@@ -61,7 +61,7 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
61
61
  // carries the worker's own error text. See the correlated "error" case below.
62
62
  const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
63
63
 
64
- export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
64
+ export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, warmExportKernel, setHostPane, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker }) {
65
65
  return {
66
66
  ready, dispose, setParams,
67
67
  // Part-declared animation playback (spec 2026-08-02): animations are
@@ -110,6 +110,13 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
110
110
  onContextLost: (listener) => viewer.onContextLost(listener),
111
111
  listExportableParts,
112
112
  exportParts,
113
+ // Pay the exact kernel's cold boot ahead of an export. STEP is pinned to
114
+ // OCCT, whose ~11 MB WASM loads on its first job, so a Manifold-previewed
115
+ // part's STEP export otherwise pays that boot inside the export itself.
116
+ // Call this when an export becomes likely (a download dialog opening) to
117
+ // move the wait off the moment the user asked for a file. Best-effort:
118
+ // resolves true/false, never rejects, and is a cheap no-op once warm.
119
+ warmExportKernel: warmExportKernel ?? (() => Promise.resolve(false)),
113
120
  // Narrow-layout pane selection, for a host that draws its own tab bar
114
121
  // (partforge-cloud does, at the window level). Defaulted to a no-op so the
115
122
  // handle's shape never depends on whether this mount resolved a rail.
@@ -1135,6 +1142,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1135
1142
  listExportableParts: () =>
1136
1143
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
1137
1144
  exportParts: (opts) => exportCtl.exportParts(opts),
1145
+ warmExportKernel: () => exportCtl.warmKernel(),
1138
1146
  animation: animCtl?.runtime ?? null,
1139
1147
  measure: {
1140
1148
  isEnabled: measureMode.isEnabled,
@@ -76,7 +76,15 @@ export function runWorker(part, opts = {}) {
76
76
  return data.quality === "print" ? manifold.print : manifold.preview;
77
77
  }
78
78
  if (!occt) {
79
- postMessage({ type: "progress", phase: "loading exact kernel" }); // feedback during cold boot
79
+ // Feedback during cold boot and CORRELATED, because for a Manifold-previewed
80
+ // part this boot IS the STEP export: backendForFormat pins STEP to OCCT, so the
81
+ // export is the session's first OCCT job and pays the whole ~11 MB WASM load.
82
+ // export-controller claims replies by jobId, so an unstamped message here is
83
+ // dropped and a headless exportParts() caller shows no progress at all for the
84
+ // one phase that can outlast its timeout. Jobs with no jobId (the in-page export
85
+ // buttons) stay unstamped, so their progress still reaches mount's own busy
86
+ // indicator instead of an export controller that has nothing pending.
87
+ postMessage({ type: "progress", phase: "loading exact kernel", ...(data.jobId != null ? { jobId: data.jobId } : {}) });
80
88
  booting = booting ?? occtKernel().then((k) => (occt = k));
81
89
  await booting;
82
90
  }
package/types/index.d.ts CHANGED
@@ -412,6 +412,17 @@ export interface PartRuntime {
412
412
  * build/export failure or an empty selection.
413
413
  */
414
414
  exportParts(opts: ExportPartsOptions): Promise<void>;
415
+ /**
416
+ * Pay the exact kernel's cold boot ahead of an export. STEP is pinned to
417
+ * OCCT, whose ~11 MB WASM loads on its first job, so a Manifold-previewed
418
+ * part's STEP export otherwise pays that boot inside the export itself.
419
+ * Call this when an export becomes likely — a download dialog opening — to
420
+ * move the wait off the moment the user asked for a file.
421
+ *
422
+ * Best-effort: resolves `true` once the kernel is up, `false` on any failure
423
+ * or teardown, and never rejects. A no-op once the kernel is warm.
424
+ */
425
+ warmExportKernel(): Promise<boolean>;
415
426
  /**
416
427
  * Narrow-layout pane selection, for a host that draws its own tab bar.
417
428
  * `null` hands selection back to partforge's built-in bar.