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 +16 -0
- package/package.json +1 -1
- package/src/framework/cutaway-controls.js +7 -1
- package/src/framework/cutaway-math.js +9 -2
- package/src/framework/cutaway.js +59 -0
- package/src/framework/mount.js +38 -2
- package/src/framework/viewer-controls.js +7 -0
- package/src/framework/viewer.js +13 -0
- package/types/index.d.ts +48 -0
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
|
@@ -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:
|
|
54
|
+
size: cutawayPoseSize(box),
|
|
48
55
|
};
|
|
49
56
|
}
|
|
50
57
|
|
package/src/framework/cutaway.js
CHANGED
|
@@ -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,
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|