partforge 0.94.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.
package/README.md CHANGED
@@ -120,9 +120,25 @@ runtime.attachTooltips([{ element: myButton }]); // host chrome buttons join th
120
120
  // aria-label, or a per-entry getLabel()); returns
121
121
  // { sync, hide, detach }, auto-detached on dispose()
122
122
  const off = runtime.onContextLost(() => {}); // WebGL context loss; returns an unsubscribe
123
+ const carried = runtime.getViewerState(); // camera + projection + cutaway, as plain JSON
123
124
  runtime.dispose(); // stops loops, workers, observers, listeners; frees GPU resources
125
+ mount(nextPart, { createWorker, elements, viewerState: carried }); // resumes the view
124
126
  ```
125
127
 
128
+ **Carry the view across a remount.** A host that applies edits by mounting a
129
+ new part — rather than by `setParams` — starts each mount from the part's
130
+ default framing, so the camera snaps back and the cutaway closes every time the
131
+ user changes anything. Snapshot with `runtime.getViewerState()` *before*
132
+ `dispose()` and hand the result to the next `mount()` as `viewerState`: the
133
+ part changes, the user's view of it does not.
134
+
135
+ Restore is best-effort per field, so a state that no longer fits is dropped
136
+ rather than fatal. The cut plane's world pose is restored exactly; its
137
+ on-screen size is re-derived from the new geometry, since that is a property of
138
+ the part rather than of the user's choice. Omit `viewerState` on a first mount
139
+ — the viewer then restores its own persisted camera and projection, as it
140
+ always has.
141
+
126
142
  **Park the viewer when you hide it.** A host that hides the canvas with
127
143
  `display: none` needs nothing — the container collapses and the ResizeObserver
128
144
  shrinks the drawing buffer for free. A host that hides it any other way
@@ -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.94.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",
@@ -25,7 +25,7 @@ function actionButton(label, title) {
25
25
  // Wire the optional cutaway button to the viewer and create its contextual
26
26
  // actions. Hosts that omit the primary button opt out of all DOM behavior.
27
27
  export function attachCutawayControls(viewer, { cutaway: button } = {}, { tooltip, escapeGuard } = {}) {
28
- if (!button) return { reset: noop, detach: noop };
28
+ if (!button) return { reset: noop, sync: noop, detach: noop };
29
29
 
30
30
  const canvas = viewer.domElement;
31
31
  const addedCanvasTabIndex = !canvas.hasAttribute("tabindex");
@@ -109,6 +109,12 @@ export function attachCutawayControls(viewer, { cutaway: button } = {}, { toolti
109
109
 
110
110
  return {
111
111
  reset: disable,
112
+ // Re-read the viewer and repaint the button. Every path in here that
113
+ // changes the mode already calls it; this exposes it for the one caller
114
+ // that turns the cutaway on from OUTSIDE the button — mount()'s restore of
115
+ // a carried viewer state, which would otherwise come back sliced open under
116
+ // a button still reading "off", with its Flip/Reset row missing.
117
+ sync,
112
118
  detach() {
113
119
  if (detached) return;
114
120
  detached = true;
@@ -28,9 +28,16 @@ export function nearestCanonicalAxis(direction, target = new THREE.Vector3()) {
28
28
  return target.set(0, 0, 0).setComponent(bestIndex, components[bestIndex] < 0 ? -1 : 1);
29
29
  }
30
30
 
31
+ // The plane's on-screen extent, as a function of the part's bounds ALONE.
32
+ // Split out of initialCutawayPose so a restored pose can be re-sized against
33
+ // whatever geometry it is landing on without inheriting anything else from the
34
+ // pose it came from — see the cutaway's setState.
35
+ export function cutawayPoseSize(box) {
36
+ return Math.max(box.getSize(new THREE.Vector3()).length(), 1) * 1.25;
37
+ }
38
+
31
39
  export function initialCutawayPose(box, camera) {
32
40
  const position = box.getCenter(new THREE.Vector3());
33
- const diagonal = Math.max(box.getSize(new THREE.Vector3()).length(), 1);
34
41
  // Square the cut plane up with the part rather than the camera: the axis
35
42
  // nearest the view direction, so the near half is still what gets cut away.
36
43
  const normal = nearestCanonicalAxis(
@@ -44,7 +51,7 @@ export function initialCutawayPose(box, camera) {
44
51
  return {
45
52
  position,
46
53
  quaternion,
47
- size: diagonal * 1.25,
54
+ size: cutawayPoseSize(box),
48
55
  };
49
56
  }
50
57
 
@@ -2,6 +2,7 @@ import * as THREE from "three";
2
2
 
3
3
  import { createCutawayGizmo } from "./cutaway-gizmo.js";
4
4
  import {
5
+ cutawayPoseSize,
5
6
  initialCutawayPose,
6
7
  planeFromPose,
7
8
  pointSurvivesPlane,
@@ -362,6 +363,62 @@ export function createCutaway({
362
363
  return true;
363
364
  }
364
365
 
366
+ // --- state carry-over ------------------------------------------------------
367
+ // The cutaway lives and dies with its mount, and an embedder that applies
368
+ // every edit by REMOUNTING (partforge-cloud does — the agent's edits, undo,
369
+ // redo, a settings commit) would otherwise close the user's slice on every
370
+ // turn. These two carry it across; mount.js owns the handoff.
371
+ //
372
+ // The snapshot is plain JSON on purpose. It outlives the mount that produced
373
+ // it, so a live THREE object would hand the next mount a reference into a
374
+ // disposed scene — and a host free to store or post it needs something
375
+ // structured-cloneable either way.
376
+ const isFiniteTuple = (value, length) =>
377
+ Array.isArray(value) && value.length === length && value.every(Number.isFinite);
378
+
379
+ function getState() {
380
+ // `size` is deliberately absent. It is a function of the part's bounds, and
381
+ // the part is exactly what changed between the snapshot and the restore —
382
+ // carrying it would size the cap and the gizmo for geometry that no longer
383
+ // exists. What the user actually chose is where the plane sits, which way
384
+ // it faces, and the flip; setState re-derives the rest.
385
+ if (!enabled || !pose) return { enabled: false, flipped: false, pose: null };
386
+ return {
387
+ enabled: true,
388
+ flipped,
389
+ pose: {
390
+ position: pose.position.toArray(),
391
+ quaternion: pose.quaternion.toArray(),
392
+ },
393
+ };
394
+ }
395
+
396
+ function setState(state) {
397
+ if (disposed || disabling) return false;
398
+ // A snapshot taken with the cutaway off carries nothing to restore. This is
399
+ // not an instruction to disable — setState only ever turns the mode ON, so
400
+ // restoring into a fresh mount (already off) is correctly a no-op.
401
+ if (!state?.enabled) return true;
402
+ // Enable first: this is the path that refuses when the restore is
403
+ // impossible (no stencil, no geometry yet), and it seeds a pose against the
404
+ // CURRENT bounds for the malformed-snapshot fallback below to land on.
405
+ if (!setEnabled(true)) return false;
406
+ if (!isFiniteTuple(state.pose?.position, 3) || !isFiniteTuple(state.pose?.quaternion, 4)) {
407
+ return true; // enabled at the fresh pose, which beats refusing the restore outright
408
+ }
409
+ const bounds = validBounds(getBounds);
410
+ flipped = Boolean(state.flipped); // read by applyPose, through planeFromPose and gizmo.setFlipped
411
+ applyPose({
412
+ position: new THREE.Vector3().fromArray(state.pose.position),
413
+ quaternion: new THREE.Quaternion().fromArray(state.pose.quaternion).normalize(),
414
+ // Re-derived, never carried — see getState above. The fallback covers
415
+ // setState on an ALREADY-enabled cutaway, where setEnabled returned early
416
+ // and bounds may since have gone away.
417
+ size: bounds ? cutawayPoseSize(bounds) : pose.size,
418
+ }, { activeAppearance: true });
419
+ return true;
420
+ }
421
+
365
422
  function setTheme(mode, edgeColor) {
366
423
  if (disposed) return false;
367
424
  theme = mode;
@@ -526,6 +583,8 @@ export function createCutaway({
526
583
  resyncSubpart,
527
584
  reset,
528
585
  flip,
586
+ getState,
587
+ setState,
529
588
  setTheme,
530
589
  setViewportSize,
531
590
  isPointVisible,
@@ -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
@@ -85,6 +85,20 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
85
85
  ? { ...opts, recenter: false }
86
86
  : opts,
87
87
  ),
88
+ // Everything about the CURRENT VIEW that a host would lose by remounting,
89
+ // as one plain-JSON token: pass it back as mount()'s `viewerState` and the
90
+ // remount comes up where the user left it. An embedder that applies edits
91
+ // by remounting (partforge-cloud applies every one that way) needs this or
92
+ // the camera snaps and the cutaway closes on every turn.
93
+ //
94
+ // Read at teardown time, so it is the LIVE pose — unlike the camera the
95
+ // viewer persists for a page reload, which only records the end of a drag
96
+ // and so misses a view-cube click, Reframe, or an animation cue.
97
+ getViewerState: () => ({
98
+ camera: viewer.getCameraState(),
99
+ projection: viewer.getProjection?.() ?? "perspective",
100
+ cutaway: viewer.getCutawayState?.() ?? null,
101
+ }),
88
102
  // Park/unpark the viewer: stops the render loop and frees the drawing
89
103
  // buffer and the cached capture target. For an embedder that hides the
90
104
  // canvas without unmounting it — `visibility: hidden`, an off-screen tab —
@@ -96,6 +110,13 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
96
110
  onContextLost: (listener) => viewer.onContextLost(listener),
97
111
  listExportableParts,
98
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)),
99
120
  // Narrow-layout pane selection, for a host that draws its own tab bar
100
121
  // (partforge-cloud does, at the window level). Defaulted to a no-op so the
101
122
  // handle's shape never depends on whether this mount resolved a rail.
@@ -264,6 +285,13 @@ function createCleanupStack() {
264
285
  // // rather than at its own hi-DPI size. Still hundreds of
265
286
  // // KB of base64 apiece, so a host should not assume this
266
287
  // // payload is small, only that it is bounded.
288
+ // viewerState: ViewerState // a previous mount's runtime.getViewerState(), handed back to
289
+ // // resume the camera, projection and cutaway where that mount
290
+ // // left them. For a host that applies edits by REMOUNTING: the
291
+ // // part changed, the user's view of it should not. Omit on a
292
+ // // first mount — the viewer then restores its own persisted
293
+ // // camera as before. Restore is best-effort per field: a pose
294
+ // // this part cannot support is dropped, never fatal.
267
295
  // annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
268
296
  // // Send in the sketch toolbar alongside the other tools.
269
297
  // // "host" drops it: the host draws its own send control —
@@ -276,6 +304,7 @@ function createCleanupStack() {
276
304
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
277
305
  export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
278
306
  fontCatalog,
307
+ viewerState,
279
308
  annotateSend = "viewbar",
280
309
  container: legacyContainer, controls: legacyControls } = {}) {
281
310
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
@@ -508,7 +537,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
508
537
  // declares it, so an embedder gets it for free. Restored BEFORE any framing
509
538
  // happens so a reload into ortho frames once instead of framing in
510
539
  // perspective and then visibly re-framing.
511
- viewer.setProjection(loadProjection());
540
+ // A carried state outranks the persisted preference: it is this session's
541
+ // live answer, where the stored one is the last page-reload's.
542
+ viewer.setProjection(viewerState?.projection ?? loadProjection());
512
543
  const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
513
544
  cleanup.defer(() => viewcube.detach());
514
545
  cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
@@ -710,8 +741,20 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
710
741
  if (frame) {
711
742
  framedView = view();
712
743
  if (!cameraRestored) {
713
- const cam = loadCamera();
744
+ // A carried camera beats the persisted one for the same reason the
745
+ // projection above does — and it is also the accurate one, since the
746
+ // persisted pose is only written at the end of an orbit drag.
747
+ const cam = viewerState?.camera ?? loadCamera();
714
748
  if (cam) viewer.setCameraState(cam);
749
+ // The cutaway goes back on AFTER the camera and only here, on the
750
+ // first accepted build: enabling it needs the sub-parts registered
751
+ // and real bounds to size the plane against, neither of which exists
752
+ // until showAssembly above has run. (Its initial pose also reads the
753
+ // camera direction, so a restore before the camera would seed the
754
+ // fallback pose from the wrong view.)
755
+ if (viewerState?.cutaway?.enabled && viewer.setCutawayState?.(viewerState.cutaway)) {
756
+ cutawayChrome.sync(); // the button was not what turned it on
757
+ }
715
758
  cameraRestored = true;
716
759
  }
717
760
  }
@@ -1099,6 +1142,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
1099
1142
  listExportableParts: () =>
1100
1143
  exportablePartNames(part, params).map((name) => ({ name, label: partLabel(part, name) })),
1101
1144
  exportParts: (opts) => exportCtl.exportParts(opts),
1145
+ warmExportKernel: () => exportCtl.warmKernel(),
1102
1146
  animation: animCtl?.runtime ?? null,
1103
1147
  measure: {
1104
1148
  isEnabled: measureMode.isEnabled,
@@ -50,6 +50,13 @@ export function attachViewerControls(
50
50
 
51
51
  return {
52
52
  detach: () => {
53
+ // Third save site, and the one that catches what the other two miss: the
54
+ // `end` event above fires for an orbit or a wheel-zoom, but NOT for a
55
+ // view-cube click, Reframe, or an animation camera cue, so a session that
56
+ // finished on one of those used to persist a pose the user had already
57
+ // moved away from. Taking the live pose at teardown makes the stored
58
+ // camera honest whatever last moved it.
59
+ saveCamera(viewer.getCameraState());
53
60
  themeBtn?.removeEventListener("click", onThemeClick);
54
61
  reframeBtn?.removeEventListener("click", onReframeClick);
55
62
  window.removeEventListener("pagehide", onPageHide);
@@ -765,6 +765,16 @@ export function createViewer(container, part) {
765
765
  return result;
766
766
  }
767
767
 
768
+ // Restoring a snapshot enables the mode, so it reassigns sub-part materials
769
+ // exactly the way setCutawayEnabled above does — and therefore has to
770
+ // re-assert live fades for the same reason a paused mid-fade part would
771
+ // otherwise stick at full opacity.
772
+ function setCutawayState(state) {
773
+ const result = cutaway.setState(state);
774
+ reassertLiveFades();
775
+ return result;
776
+ }
777
+
768
778
  // Swap the scene background, grid, and edge-line colors for the given theme.
769
779
  function setTheme(mode) {
770
780
  const t = THEME[mode] ?? THEME.dark;
@@ -1386,6 +1396,9 @@ export function createViewer(container, part) {
1386
1396
  cutawaySupported: () => cutaway.isSupported,
1387
1397
  cutawayEnabled: () => cutaway.isEnabled,
1388
1398
  setCutawayEnabled,
1399
+ // Carry the slice across a remount — see cutaway.js's getState/setState.
1400
+ getCutawayState: cutaway.getState,
1401
+ setCutawayState,
1389
1402
  flipCutaway: cutaway.flip,
1390
1403
  resetCutaway: cutaway.reset,
1391
1404
  isWorldPointVisible: cutaway.isPointVisible,
@@ -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
@@ -132,12 +132,52 @@ export interface MountOptions {
132
132
  * the sketch with a typed message — and calls `runtime.annotate.send()`.
133
133
  */
134
134
  annotateSend?: "viewbar" | "host";
135
+ /**
136
+ * A previous mount's `runtime.getViewerState()`, handed back so this mount
137
+ * resumes the camera, projection and cutaway where that one left them. For a
138
+ * host that applies edits by REMOUNTING: the part changed, the user's view of
139
+ * it should not.
140
+ *
141
+ * Omit on a first mount — the viewer then restores its own persisted camera
142
+ * and projection as before. Restore is best-effort per field: a pose this
143
+ * part cannot support is dropped, never fatal.
144
+ */
145
+ viewerState?: ViewerState | null;
135
146
  /** @deprecated alias for `elements.viewer`. */
136
147
  container?: HTMLElement | null;
137
148
  /** @deprecated alias for `elements.controls`. */
138
149
  controls?: HTMLElement | null;
139
150
  }
140
151
 
152
+ /** A cut plane, as `ViewerState` carries it across a remount. */
153
+ export interface CutawayState {
154
+ /** Whether the cutaway was on. `false` means there is nothing to restore. */
155
+ enabled: boolean;
156
+ /** Which half the plane keeps. */
157
+ flipped: boolean;
158
+ /**
159
+ * The plane's world pose: `position` as `[x, y, z]`, `quaternion` as
160
+ * `[x, y, z, w]`. `null` while disabled. The plane's on-screen SIZE is
161
+ * deliberately absent — it follows the part's bounds, and the part is what
162
+ * changed, so a restore re-derives it from the geometry it lands on.
163
+ */
164
+ pose: { position: [number, number, number]; quaternion: [number, number, number, number] } | null;
165
+ }
166
+
167
+ /**
168
+ * Everything about the current view that a remount would otherwise lose. Plain
169
+ * JSON: it outlives the mount that produced it, so nothing in it points into a
170
+ * disposed scene, and a host may store or post it rather than only handing it
171
+ * straight back. Read it with `runtime.getViewerState()`; hand it back as
172
+ * `MountOptions.viewerState`.
173
+ */
174
+ export interface ViewerState {
175
+ /** The live camera pose, or `null` when the viewer could not report one. */
176
+ camera: { pos: [number, number, number]; target: [number, number, number] } | null;
177
+ projection: "perspective" | "orthographic";
178
+ cutaway: CutawayState | null;
179
+ }
180
+
141
181
  export interface ExportPartsOptions {
142
182
  /** Sub-part names, as `listExportableParts()` reports them. */
143
183
  parts: string[];
@@ -346,6 +386,14 @@ export interface PartRuntime {
346
386
  * unmounting it. Captures still work while parked. Safe after `dispose()`.
347
387
  */
348
388
  setActive(active: boolean): void;
389
+ /**
390
+ * Snapshot the camera, projection and cutaway so a REMOUNT can resume them —
391
+ * pass the result as the next `mount()`'s `viewerState`. Read at teardown
392
+ * time, so it is the live pose, unlike the camera the viewer persists for a
393
+ * page reload (which only records the end of an orbit drag, and so misses a
394
+ * view-cube click, Reframe, or an animation cue).
395
+ */
396
+ getViewerState(): ViewerState;
349
397
  /**
350
398
  * Subscribe to WebGL context loss — i.e. the GPU or the OS gave up — so a host
351
399
  * can say so rather than showing a dead canvas. The listener takes no
@@ -364,6 +412,17 @@ export interface PartRuntime {
364
412
  * build/export failure or an empty selection.
365
413
  */
366
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>;
367
426
  /**
368
427
  * Narrow-layout pane selection, for a host that draws its own tab bar.
369
428
  * `null` hands selection back to partforge's built-in bar.