partforge 0.94.0 → 0.95.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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.94.0",
3
+ "version": "0.95.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,
@@ -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 —
@@ -264,6 +278,13 @@ function createCleanupStack() {
264
278
  // // rather than at its own hi-DPI size. Still hundreds of
265
279
  // // KB of base64 apiece, so a host should not assume this
266
280
  // // payload is small, only that it is bounded.
281
+ // viewerState: ViewerState // a previous mount's runtime.getViewerState(), handed back to
282
+ // // resume the camera, projection and cutaway where that mount
283
+ // // left them. For a host that applies edits by REMOUNTING: the
284
+ // // part changed, the user's view of it should not. Omit on a
285
+ // // first mount — the viewer then restores its own persisted
286
+ // // camera as before. Restore is best-effort per field: a pose
287
+ // // this part cannot support is dropped, never fatal.
267
288
  // annotateSend: "viewbar" | "host" // who owns the Send affordance. "viewbar" (default) puts
268
289
  // // Send in the sketch toolbar alongside the other tools.
269
290
  // // "host" drops it: the host draws its own send control —
@@ -276,6 +297,7 @@ function createCleanupStack() {
276
297
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
277
298
  export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
278
299
  fontCatalog,
300
+ viewerState,
279
301
  annotateSend = "viewbar",
280
302
  container: legacyContainer, controls: legacyControls } = {}) {
281
303
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
@@ -508,7 +530,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
508
530
  // declares it, so an embedder gets it for free. Restored BEFORE any framing
509
531
  // happens so a reload into ortho frames once instead of framing in
510
532
  // perspective and then visibly re-framing.
511
- viewer.setProjection(loadProjection());
533
+ // A carried state outranks the persisted preference: it is this session's
534
+ // live answer, where the stored one is the last page-reload's.
535
+ viewer.setProjection(viewerState?.projection ?? loadProjection());
512
536
  const viewcube = attachViewcubeControls(viewer, { stage: els.viewer }, { tooltip });
513
537
  cleanup.defer(() => viewcube.detach());
514
538
  cleanup.defer(viewer.onProjectionChange((mode) => saveProjection(mode)));
@@ -710,8 +734,20 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
710
734
  if (frame) {
711
735
  framedView = view();
712
736
  if (!cameraRestored) {
713
- const cam = loadCamera();
737
+ // A carried camera beats the persisted one for the same reason the
738
+ // projection above does — and it is also the accurate one, since the
739
+ // persisted pose is only written at the end of an orbit drag.
740
+ const cam = viewerState?.camera ?? loadCamera();
714
741
  if (cam) viewer.setCameraState(cam);
742
+ // The cutaway goes back on AFTER the camera and only here, on the
743
+ // first accepted build: enabling it needs the sub-parts registered
744
+ // and real bounds to size the plane against, neither of which exists
745
+ // until showAssembly above has run. (Its initial pose also reads the
746
+ // camera direction, so a restore before the camera would seed the
747
+ // fallback pose from the wrong view.)
748
+ if (viewerState?.cutaway?.enabled && viewer.setCutawayState?.(viewerState.cutaway)) {
749
+ cutawayChrome.sync(); // the button was not what turned it on
750
+ }
715
751
  cameraRestored = true;
716
752
  }
717
753
  }
@@ -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,
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