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 +16 -0
- package/docs/AUTHORING-PARTS.md +9 -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/export-controller.js +25 -1
- package/src/framework/jobs.js +7 -0
- package/src/framework/mount.js +47 -3
- package/src/framework/viewer-controls.js +7 -0
- package/src/framework/viewer.js +13 -0
- package/src/framework/worker.js +9 -1
- package/types/index.d.ts +59 -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/docs/AUTHORING-PARTS.md
CHANGED
|
@@ -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
|
@@ -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,
|
|
@@ -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
|
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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.
|
package/src/framework/mount.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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);
|
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/src/framework/worker.js
CHANGED
|
@@ -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
|
-
|
|
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.
|