partforge 0.146.4 → 0.149.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 +25 -0
- package/docs/AUTHORING-PARTS.md +49 -0
- package/package.json +12 -1
- package/src/framework/app.css +66 -1
- package/src/framework/appearance-card.js +331 -0
- package/src/framework/default-view.js +6 -3
- package/src/framework/gltf/decode-preview.js +89 -0
- package/src/framework/gltf/encode-preview.js +142 -0
- package/src/framework/materials/overrides.js +120 -0
- package/src/framework/materials/thumb-cache.js +33 -0
- package/src/framework/mount-preview.js +157 -0
- package/src/framework/mount.js +247 -52
- package/src/framework/parts-list.js +116 -0
- package/src/framework/preview-gate.js +37 -0
- package/src/framework/sub-part-list.js +44 -0
- package/src/framework/view-style-controls.js +110 -5
- package/src/framework/viewbar-clear.js +23 -0
- package/src/framework/viewer.js +377 -49
- package/src/gltf.js +6 -0
- package/src/index.js +5 -0
- package/src/testing.js +1 -0
- package/types/gltf.d.ts +40 -0
- package/types/index.d.ts +80 -0
- package/types/testing.d.ts +3 -0
package/src/framework/mount.js
CHANGED
|
@@ -14,6 +14,8 @@ import { ENVIRONMENTS } from "./materials/environments.js";
|
|
|
14
14
|
import { declaresMaterials, resolveMaterial } from "./materials/resolve.js";
|
|
15
15
|
import { printFrameMatrix } from "./materials/print-frame.js";
|
|
16
16
|
import { burnsFor, realisticDisplay, sheetFrameFor } from "./materials/sheet-look.js";
|
|
17
|
+
import { pruneOverrides } from "./materials/overrides.js";
|
|
18
|
+
import { subPartRows } from "./sub-part-list.js";
|
|
17
19
|
import { buildControls } from "./controls.js";
|
|
18
20
|
import { recordedRelevantKeys, subPartParamKeys } from "./param-deps.js";
|
|
19
21
|
import { createMeshCache } from "./mesh-cache.js";
|
|
@@ -39,7 +41,8 @@ import { createAnnotateMode } from "./annotate/annotate-mode.js";
|
|
|
39
41
|
import { attachAnnotateControls } from "./annotate/annotate-controls.js";
|
|
40
42
|
import { attachSketchToolbar } from "./annotate/sketch-toolbar.js";
|
|
41
43
|
import { attachViewcubeControls } from "./viewcube/viewcube-controls.js";
|
|
42
|
-
import {
|
|
44
|
+
import { createPreviewGate } from "./preview-gate.js";
|
|
45
|
+
import { observeViewbarClear } from "./viewbar-clear.js";
|
|
43
46
|
|
|
44
47
|
// The mount handle, factored out so its shape is unit-testable without booting
|
|
45
48
|
// the full mount() pipeline (WASM + workers + DOM).
|
|
@@ -69,9 +72,15 @@ const IMPORT_MESH_BROKEN_MESSAGE = "STEP import tessellation failed to satisfy t
|
|
|
69
72
|
// carries the worker's own error text. See the correlated "error" case below.
|
|
70
73
|
const importTessellateFailedMessage = (workerMessage) => `STEP import tessellation failed — ${workerMessage}`;
|
|
71
74
|
|
|
72
|
-
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, controlsFor, renderMode, environment, declaresMaterials, renderViews }) {
|
|
75
|
+
export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, listExportFormats, exportParts, warmExportKernel, setHostPane, setRailLayout, animation, getView, setView, captureView, attachTooltips, measure, annotate, projection, pickMarker, getPanelState, getPanelErrors, controlsFor, renderMode, environment, declaresMaterials, renderViews, isPreviewActive, exportPreview, pendingCutaway, listSubParts, openAppearance, setDisplaySaveState }) {
|
|
73
76
|
return {
|
|
74
77
|
ready, dispose, setParams,
|
|
78
|
+
// Whether the `preview` GLB handed to mount() is on screen (see mount's
|
|
79
|
+
// preview block). A GETTER, read live: a host that copies the handle object
|
|
80
|
+
// freezes the value it had at that moment.
|
|
81
|
+
get previewActive() { return isPreviewActive?.() ?? false; },
|
|
82
|
+
// The view on screen as a preview GLB (partforge/gltf), poses baked in.
|
|
83
|
+
exportPreview: exportPreview ?? (() => Promise.reject(new Error("exportPreview: this mount cannot encode a preview"))),
|
|
75
84
|
// Part-declared animation playback (spec 2026-08-02): animations are
|
|
76
85
|
// VIEW-owned, so this is null only when NO view declares any.
|
|
77
86
|
// { play(name?), pause(), seek(t), stop(), state() } — play(name) resolves
|
|
@@ -113,7 +122,9 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
113
122
|
getViewerState: () => ({
|
|
114
123
|
camera: viewer.getCameraState(),
|
|
115
124
|
projection: viewer.getProjection?.() ?? "perspective",
|
|
116
|
-
cutaway
|
|
125
|
+
// A carried cutaway not yet restored (the preview holds it back) is still
|
|
126
|
+
// what the user last had: echo it, or a remount in that window drops it.
|
|
127
|
+
cutaway: pendingCutaway?.() ?? viewer.getCutawayState?.() ?? null,
|
|
117
128
|
// The mode the user is headed for, not only the one on screen: a
|
|
118
129
|
// realistic restore or switch still loading reads "realistic", so a
|
|
119
130
|
// remount mid-load (the cloud remounts on every edit) doesn't drop it.
|
|
@@ -123,7 +134,29 @@ export function makeHandle({ ready, dispose, viewer, setParams, listExportablePa
|
|
|
123
134
|
// from meta.environment (or the default) would outrank the part's own
|
|
124
135
|
// meta.environment on the next mount, masking an edit to it.
|
|
125
136
|
...(viewer.isEnvironmentChosen?.() ? { environment: viewer.getEnvironment() } : {}),
|
|
137
|
+
// Only while something is overridden: an empty map would be noise in a
|
|
138
|
+
// token hosts compare and store.
|
|
139
|
+
...(() => { const d = viewer.getDisplayOverrides?.(); return d && Object.keys(d).length ? { display: d } : {}; })(),
|
|
126
140
|
}),
|
|
141
|
+
// --- per-sub-part display overrides (spec 2026-10-13 part appearance controls) ---
|
|
142
|
+
// Live, no rebuild: appearance keys (material, colour, finish) merge over
|
|
143
|
+
// the authored `display` for every render; `hidden` and `opacity` are how
|
|
144
|
+
// the user is looking and never reach a material read, an export or a
|
|
145
|
+
// host's save. All of it rides getViewerState().display above.
|
|
146
|
+
setSubPartDisplay: (name, patch) => viewer.setSubPartDisplay?.(name, patch) ?? null,
|
|
147
|
+
getSubPartDisplay: (name) => viewer.getSubPartDisplayState?.(name) ?? null,
|
|
148
|
+
clearSubPartDisplay: (opts) => viewer.clearSubPartDisplay?.(opts),
|
|
149
|
+
onSubPartDisplayChange: (cb) => viewer.onDisplayChange?.(cb) ?? (() => {}),
|
|
150
|
+
// Every sub-part as a row (sub-part-list.js), for a host's own list; the
|
|
151
|
+
// active view unless `view` names another.
|
|
152
|
+
listSubParts: listSubParts ?? (() => []),
|
|
153
|
+
// Open the popover on the appearance card for `name`; false when there is
|
|
154
|
+
// no popover (a host with neither #viewbar nor a view cube stack).
|
|
155
|
+
openAppearance: openAppearance ?? (() => false),
|
|
156
|
+
// "saved" | "screen-only" | "blocked": the card's footer copy. A host that
|
|
157
|
+
// persists overrides into source says "saved"; one that cannot, for this
|
|
158
|
+
// sub-part, says "blocked"; the default is "screen-only".
|
|
159
|
+
setDisplaySaveState: setDisplaySaveState ?? (() => {}),
|
|
127
160
|
// Every custom control's transient state (selection, a scroll position),
|
|
128
161
|
// keyed by param, as plain JSON — the panel's twin of getViewerState. Hand
|
|
129
162
|
// it back as mount()'s `panelState` and a remount comes up with the same
|
|
@@ -242,8 +275,10 @@ function createCleanupStack() {
|
|
|
242
275
|
// mesh-validity cache, and the geometry workers. The app supplies `createWorker(name)`
|
|
243
276
|
// so Vite can bundle the worker (see geometry-service.js).
|
|
244
277
|
//
|
|
245
|
-
// Embedding contract (0.
|
|
246
|
-
//
|
|
278
|
+
// Embedding contract (0.147.0: a cached `preview` GLB is drawn before the kernel
|
|
279
|
+
// boots and swapped in place by the first build; 0.141.0: a place()-able sub-part
|
|
280
|
+
// is delivered in its canonical frame and posed by the viewer; pose-only edits
|
|
281
|
+
// never rebuild):
|
|
247
282
|
// const runtime = mount(part, { createWorker, elements, onBuild, onPick, onDownload, onViewChange });
|
|
248
283
|
// await runtime.ready; // first successful build of the default view
|
|
249
284
|
// runtime.setParams({ openAngle: 45 }); // programmatic edit; pose-only changes apply instantly
|
|
@@ -358,6 +393,15 @@ function createCleanupStack() {
|
|
|
358
393
|
// // switching the live view; "cad" is exactly captureViews(),
|
|
359
394
|
// // CAD in either live mode
|
|
360
395
|
// runtime.dispose(); // full teardown
|
|
396
|
+
// runtime.previewActive // true while the `preview` GLB is on screen (decoded, not yet
|
|
397
|
+
// // replaced by a whole live build): picking, hover labels,
|
|
398
|
+
// // measure, sketch and cutaway wait; orbit, the view cube,
|
|
399
|
+
// // the view style and the rail work
|
|
400
|
+
// await runtime.exportPreview(); // the view on screen as a preview GLB (Uint8Array,
|
|
401
|
+
// // partforge/gltf), fast-path poses baked in. Rejects while
|
|
402
|
+
// // previewActive, before the view is fully built (a pending
|
|
403
|
+
// // rebuild counts), for a view other than the one on screen,
|
|
404
|
+
// // and after dispose
|
|
361
405
|
// onBuild fires per completed build, so it does NOT fire for a pose-only edit —
|
|
362
406
|
// those are repaired in the viewer and produce no build at all.
|
|
363
407
|
// onViewChange fires once synchronously during mount with the initial resolved
|
|
@@ -380,13 +424,20 @@ function createCleanupStack() {
|
|
|
380
424
|
// // payload is small, only that it is bounded.
|
|
381
425
|
// viewerState: ViewerState // a previous mount's runtime.getViewerState(), handed back to
|
|
382
426
|
// // resume the camera, projection, cutaway, render mode,
|
|
383
|
-
// // environment
|
|
384
|
-
// //
|
|
427
|
+
// // environment, per-style feature-line preferences and
|
|
428
|
+
// // per-sub-part display overrides (`display`, pruned for
|
|
429
|
+
// // this part) where that mount
|
|
385
430
|
// // left them. For a host that applies edits by REMOUNTING: the
|
|
386
431
|
// // part changed, the user's view of it should not. Omit on a
|
|
387
432
|
// // first mount — the viewer then restores its own persisted
|
|
388
433
|
// // camera as before. Restore is best-effort per field: a pose
|
|
389
434
|
// // this part cannot support is dropped, never fatal.
|
|
435
|
+
// preview: Uint8Array | ArrayBuffer // a GLB from encodePreviewGlb / exportPreview, drawn before
|
|
436
|
+
// // the worker is ready and replaced sub-part by sub-part by
|
|
437
|
+
// // the first live build, camera kept. Only a file for the view
|
|
438
|
+
// // on screen is drawn, and only its sub-parts of that view; a
|
|
439
|
+
// // file for another view, or one that will not decode, is
|
|
440
|
+
// // logged once and ignored.
|
|
390
441
|
// panelState: PanelState // a previous mount's runtime.getPanelState(): the transient
|
|
391
442
|
// // state of the part's custom controls (a selected tile), keyed
|
|
392
443
|
// // by param. Same remount story as viewerState; omit on a first
|
|
@@ -433,7 +484,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
433
484
|
fontCatalog,
|
|
434
485
|
imageCatalog,
|
|
435
486
|
onAssetUpload,
|
|
436
|
-
viewerState,
|
|
487
|
+
viewerState, preview,
|
|
437
488
|
files, panelState,
|
|
438
489
|
annotateSend = "viewbar",
|
|
439
490
|
container: legacyContainer, controls: legacyControls } = {}) {
|
|
@@ -516,6 +567,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
516
567
|
cleanup.defer(() => ui.setStatus(""));
|
|
517
568
|
cleanup.defer(() => ui.hideBusy());
|
|
518
569
|
|
|
570
|
+
// A cached preview (the `preview` option) is drawn before the kernel boots and
|
|
571
|
+
// held to the camera only — no feature ids, no live solids behind it — until
|
|
572
|
+
// the first whole live build replaces it. The gate is the flag, plus the
|
|
573
|
+
// chrome it disables meanwhile; picking and hover consult it per event. See
|
|
574
|
+
// the preview block just above `return makeHandle`.
|
|
575
|
+
const previewGate = createPreviewGate({
|
|
576
|
+
buttons: [els.chrome.cutaway, els.chrome.measure, els.chrome.annotate],
|
|
577
|
+
});
|
|
578
|
+
|
|
519
579
|
// ?backend=occt|manifold forces the backend; otherwise it's re-detected per
|
|
520
580
|
// regen with the live params (so a fillet dialed to 0 reverts to Manifold),
|
|
521
581
|
// with a params-keyed latch for runtime needs-occt reroutes — see
|
|
@@ -569,6 +629,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
569
629
|
|
|
570
630
|
// View tabs (generated from part.views) + live params. A tab switch shows the
|
|
571
631
|
// cached assembly instantly if it's current, else auto-builds what's missing.
|
|
632
|
+
// The popover's Parts section (attachViewStyleControls' `parts` seam) follows
|
|
633
|
+
// the active view; declared here because tabsCtl's onChange reads it.
|
|
634
|
+
const viewChangeListeners = new Set();
|
|
572
635
|
const tabsCtl = createViewTabs(els.tabs, part, {
|
|
573
636
|
onChange: (name) => {
|
|
574
637
|
// FIRST: the outgoing animation restores its param snapshot, so the
|
|
@@ -581,6 +644,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
581
644
|
// (and clear the posed ones' matrices) before anything else reads them.
|
|
582
645
|
fastPath.apply(viewSubParts(part, view(), params));
|
|
583
646
|
updateRelevance(); loop.kick(); animCtl?.autoplayKick();
|
|
647
|
+
for (const cb of [...viewChangeListeners]) { try { cb(name); } catch { /* listener's problem */ } }
|
|
584
648
|
onViewChange?.(name);
|
|
585
649
|
},
|
|
586
650
|
});
|
|
@@ -705,6 +769,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
705
769
|
// right rig first time rather than the default and then this one.
|
|
706
770
|
const initialEnv = viewerState?.environment ?? loadEnvironment();
|
|
707
771
|
if (initialEnv) viewer.setEnvironment(initialEnv);
|
|
772
|
+
// Carried display overrides, pruned for THIS part (materials/overrides.js):
|
|
773
|
+
// a renamed sub-part's entry goes, and an appearance key that now equals the
|
|
774
|
+
// authored value goes — which is how an owner's saved change self-cleans
|
|
775
|
+
// after the remount that applied it. Restored before the first showView so
|
|
776
|
+
// the first frame already draws the override, hidden pieces included.
|
|
777
|
+
for (const [name, override] of Object.entries(pruneOverrides(viewerState?.display, part))) {
|
|
778
|
+
viewer.setSubPartDisplay(name, override);
|
|
779
|
+
}
|
|
708
780
|
// Realistic mode by the same precedence, requested HERE rather than on the
|
|
709
781
|
// first accepted build: every mount has a fresh renderer, so the
|
|
710
782
|
// environment loads again, and starting now runs that alongside the build
|
|
@@ -729,6 +801,15 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
729
801
|
stage: els.viewer,
|
|
730
802
|
toolbar: els.viewer.querySelector("#viewbar"),
|
|
731
803
|
anchor: viewcube.element,
|
|
804
|
+
// The Parts section's seam (view-style-controls.js); an older popover ignores it.
|
|
805
|
+
parts: {
|
|
806
|
+
list: () => subPartRows(part, { view: view(), params, overrides: viewer.getDisplayOverrides?.() ?? {} }),
|
|
807
|
+
setDisplay: (name, patch) => viewer.setSubPartDisplay(name, patch),
|
|
808
|
+
clear: (opts) => viewer.clearSubPartDisplay(opts),
|
|
809
|
+
onChange: (cb) => viewer.onDisplayChange?.(cb) ?? (() => {}),
|
|
810
|
+
onViewChange: (cb) => { viewChangeListeners.add(cb); return () => viewChangeListeners.delete(cb); },
|
|
811
|
+
getState: (name) => viewer.getSubPartDisplayState(name),
|
|
812
|
+
},
|
|
732
813
|
}, { tooltip });
|
|
733
814
|
cleanup.defer(() => viewStyle.detach());
|
|
734
815
|
// setHidden takes one boolean, and there are two independent reasons to hide
|
|
@@ -755,21 +836,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
755
836
|
// action rows can change. A hidden bar (ours or a host stylesheet's)
|
|
756
837
|
// claims 0 — stage-clear.js has the why; the observer fires on the hide
|
|
757
838
|
// itself, since the bar's box goes to zero.
|
|
758
|
-
|
|
759
|
-
if (viewbarEl && typeof ResizeObserver === "function") {
|
|
760
|
-
const publishViewbarClear = () => {
|
|
761
|
-
const clear = stageClearFor(els.viewer.getBoundingClientRect(), viewbarEl.getBoundingClientRect());
|
|
762
|
-
els.viewer.style.setProperty("--pf-viewbar-clear", `${clear}px`);
|
|
763
|
-
};
|
|
764
|
-
const viewbarObserver = new ResizeObserver(publishViewbarClear);
|
|
765
|
-
viewbarObserver.observe(viewbarEl);
|
|
766
|
-
viewbarObserver.observe(els.viewer);
|
|
767
|
-
publishViewbarClear();
|
|
768
|
-
cleanup.defer(() => {
|
|
769
|
-
viewbarObserver.disconnect();
|
|
770
|
-
els.viewer.style.removeProperty("--pf-viewbar-clear");
|
|
771
|
-
});
|
|
772
|
-
}
|
|
839
|
+
cleanup.defer(observeViewbarClear(els.viewer, els.viewer.querySelector("#viewbar")));
|
|
773
840
|
// escapeScope: cutaway's Flip/Reset buttons are canvas SIBLINGS inside
|
|
774
841
|
// #viewbar, not descendants of the canvas — attaching Escape to
|
|
775
842
|
// viewer.domElement alone would leave a guarded Escape from those buttons
|
|
@@ -787,9 +854,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
787
854
|
// active — measure's highlight + dims take the pointer; annotate's overlay
|
|
788
855
|
// canvas takes it entirely.
|
|
789
856
|
const syncHoverSuppression = () =>
|
|
790
|
-
hover.setSuppressed(measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false));
|
|
857
|
+
hover.setSuppressed(previewGate.isActive() || measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false));
|
|
791
858
|
cleanup.defer(measureMode.onModeChange(syncHoverSuppression));
|
|
792
859
|
if (annotateMode) cleanup.defer(annotateMode.onModeChange(syncHoverSuppression));
|
|
860
|
+
cleanup.defer(previewGate.onChange(syncHoverSuppression));
|
|
861
|
+
// Deferred AFTER the cutaway/measure/annotate chrome attached, so at dispose
|
|
862
|
+
// (LIFO) the gate hands the buttons back before those controls restore the
|
|
863
|
+
// host's own attributes.
|
|
864
|
+
cleanup.defer(() => previewGate.end());
|
|
793
865
|
|
|
794
866
|
// Current selection context for the pickers: the active view + live params +
|
|
795
867
|
// derived values. Shared by every pick mode below.
|
|
@@ -815,7 +887,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
815
887
|
// resync on mode changes. The ?pick/?pickserver harnesses below are
|
|
816
888
|
// deliberately not guarded — one is armed by an explicit dev toggle,
|
|
817
889
|
// the other per agent request.
|
|
818
|
-
suppressed: () => measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
|
|
890
|
+
suppressed: () => previewGate.isActive() || measureMode.isEnabled() || (annotateMode?.isEnabled() ?? false),
|
|
819
891
|
onPick: (selection, anchor) => onPick({
|
|
820
892
|
selection,
|
|
821
893
|
label: selection.feature?.label ?? part.parts[selection.subPart]?.label ?? selection.subPart,
|
|
@@ -850,6 +922,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
850
922
|
|
|
851
923
|
let framedView = null; // the view the camera was last framed to (null until first show)
|
|
852
924
|
let cameraRestored = false; // saved camera applied once, on the first frame after load
|
|
925
|
+
let cutawayRestored = false; // a carried cutaway, applied once — after any preview, never on one
|
|
853
926
|
|
|
854
927
|
// Per-sub-part cache-validity tracker (Layer 1): view/version/caching change over
|
|
855
928
|
// time, so they're passed as getters; params is a stable in-place-mutated object.
|
|
@@ -946,6 +1019,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
946
1019
|
// view doesn't flicker. If nothing's built yet, show nothing.
|
|
947
1020
|
// Show the assembly, framing the camera only the first time we show a given view
|
|
948
1021
|
// (initial load / tab switch) — never on a regenerate, so zoom/orbit are kept.
|
|
1022
|
+
// A carried cutaway goes back on once, and never onto a cached preview: the
|
|
1023
|
+
// section needs live solids to cut. `cam` is the pose to place again after
|
|
1024
|
+
// it (an ortho face view's size is matched at the surface the cut decides).
|
|
1025
|
+
function restoreCarriedCutaway(cam) {
|
|
1026
|
+
cutawayRestored = true;
|
|
1027
|
+
if (viewerState?.cutaway?.enabled && viewer.setCutawayState?.(viewerState.cutaway)) {
|
|
1028
|
+
cutawayChrome.sync(); // the button was not what turned it on
|
|
1029
|
+
if (cam) viewer.setCameraState(cam);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
|
|
949
1033
|
function showView(needed) {
|
|
950
1034
|
const frame = view() !== framedView;
|
|
951
1035
|
viewer.showAssembly(needed, { frame });
|
|
@@ -959,26 +1043,23 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
959
1043
|
// only for a face-view camera, and this is the call that re-arms it.
|
|
960
1044
|
const cam = viewerState?.camera ?? loadCamera();
|
|
961
1045
|
if (cam) viewer.setCameraState(cam);
|
|
1046
|
+
cameraRestored = true;
|
|
962
1047
|
// The cutaway goes back on AFTER the camera and only here, on the
|
|
963
1048
|
// first accepted build: enabling it needs the sub-parts registered
|
|
964
1049
|
// and real bounds to size the plane against, neither of which exists
|
|
965
1050
|
// until showAssembly above has run. (Its initial pose also reads the
|
|
966
1051
|
// camera direction, so a restore before the camera would seed the
|
|
967
|
-
// fallback pose from the wrong view.)
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
// …and place the camera again now the section is back. An ortho
|
|
971
|
-
// face view's size is matched at the surface on screen, which the
|
|
972
|
-
// cut decides (the cap, or whatever survives it): the carried pose
|
|
973
|
-
// was taken WITH the cut, and the first placement above measured
|
|
974
|
-
// against the uncut part, so without this a sectioned face view
|
|
975
|
-
// changes zoom on every remount. Same pose, so a perspective
|
|
976
|
-
// camera does not move.
|
|
977
|
-
if (cam) viewer.setCameraState(cam);
|
|
978
|
-
}
|
|
979
|
-
cameraRestored = true;
|
|
1052
|
+
// fallback pose from the wrong view.) A cached preview defers it to
|
|
1053
|
+
// the live build below.
|
|
1054
|
+
if (!previewGate.isActive()) restoreCarriedCutaway(cam);
|
|
980
1055
|
}
|
|
981
1056
|
}
|
|
1057
|
+
if (cameraRestored && !cutawayRestored && !previewGate.isActive()) {
|
|
1058
|
+
// The preview held the cut back and the live build is in. Re-place the
|
|
1059
|
+
// camera the user is looking at NOW — they may have orbited the preview
|
|
1060
|
+
// — not the carried one.
|
|
1061
|
+
restoreCarriedCutaway(viewer.getCameraState());
|
|
1062
|
+
}
|
|
982
1063
|
}
|
|
983
1064
|
|
|
984
1065
|
// Frames the realistic look reads, for the geometry just delivered:
|
|
@@ -992,10 +1073,17 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
992
1073
|
// - sheet frames for the burn pass (materials/sheet-look.js): a laser sheet in a wood,
|
|
993
1074
|
// mapped back into its canonical frame — from data, no probe.
|
|
994
1075
|
// Each describes the delivered mesh (which a later pose-only repair only moves).
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1076
|
+
// Which sub-parts need frames is read off the EFFECTIVE display at the time
|
|
1077
|
+
// the frames are computed, not fixed at mount: a live material change (the
|
|
1078
|
+
// Parts section) can turn a plain piece into a layer-lined print, and it
|
|
1079
|
+
// must get its print frame without a rebuild. So every delivery's
|
|
1080
|
+
// dispatch record is kept (one small object per sub-part), and computeFrames
|
|
1081
|
+
// decides per name.
|
|
1082
|
+
const effectiveSubPart = (n) => ({ ...part.parts[n], display: viewer.effectiveDisplayOf?.(n) ?? part.parts[n].display });
|
|
1083
|
+
const isLayerLined = (n) => {
|
|
1084
|
+
try { return resolveMaterial(realisticDisplay(effectiveSubPart(n))).params.pattern === "layer-lines"; } catch { return false; }
|
|
1085
|
+
};
|
|
1086
|
+
const isBurning = (n) => burnsFor(effectiveSubPart(n));
|
|
999
1087
|
// LAZY: a delivery only records the view and params its job was DISPATCHED with
|
|
1000
1088
|
// (`dispatched`, set where the loop sends) — not the live ones, which a delivery shown
|
|
1001
1089
|
// during playback was never built at; the viewer pulls the frames (computeFrames)
|
|
@@ -1004,20 +1092,32 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1004
1092
|
// snapshot keeps a late computation describing the mesh actually delivered.
|
|
1005
1093
|
const printFrames = {};
|
|
1006
1094
|
const sheetFrames = {};
|
|
1007
|
-
const
|
|
1095
|
+
const deliveredAt = new Map(); // sub-part -> { at: { view, params }, frame } of its delivered mesh (kept, not consumed)
|
|
1096
|
+
const undrawnFrames = new Set(); // sub-part names whose frames are stale
|
|
1097
|
+
// The (print, burn) pair each name's frames were last computed for. A display
|
|
1098
|
+
// change re-stales a name only when its pair flips — hide, show, opacity and a
|
|
1099
|
+
// swap between two layer-lined (or two burning) materials leave the frames
|
|
1100
|
+
// valid, so a see-through drag in realistic mode never re-probes a placed piece.
|
|
1101
|
+
const frameNeeds = new Map();
|
|
1102
|
+
const needsKey = (n) => `${isLayerLined(n) ? 1 : 0}${isBurning(n) ? 1 : 0}`;
|
|
1008
1103
|
// `meshes` are the reply's deliveries: each one's `frame` ("canonical" | "posed"; an older
|
|
1009
1104
|
// worker sends none, so posed) rides beside the view/params so the frames describe it.
|
|
1010
1105
|
function recordFrames(meshes) {
|
|
1011
|
-
const
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
undrawnFrames.set(m.name, { at: dispatched.get(m.name) ?? null, frame: m.frame ?? "posed" }); // at null: built at params nobody recorded
|
|
1106
|
+
for (const m of meshes) {
|
|
1107
|
+
deliveredAt.set(m.name, { at: dispatched.get(m.name) ?? null, frame: m.frame ?? "posed" }); // at null: built at params nobody recorded
|
|
1108
|
+
undrawnFrames.add(m.name);
|
|
1015
1109
|
}
|
|
1016
1110
|
viewer.invalidateFrames?.();
|
|
1017
1111
|
}
|
|
1018
1112
|
function computeFrames() {
|
|
1019
1113
|
const resolvedFor = new Map(); // one resolveParams per delivery, not per sub-part
|
|
1020
|
-
for (const
|
|
1114
|
+
for (const n of undrawnFrames) {
|
|
1115
|
+
const rec = deliveredAt.get(n);
|
|
1116
|
+
if (!rec) continue;
|
|
1117
|
+
const { at, frame } = rec;
|
|
1118
|
+
const needPrint = isLayerLined(n), needBurn = isBurning(n);
|
|
1119
|
+
frameNeeds.set(n, `${needPrint ? 1 : 0}${needBurn ? 1 : 0}`);
|
|
1120
|
+
if (!needPrint && !needBurn) { delete sheetFrames[n]; continue; }
|
|
1021
1121
|
if (at && !resolvedFor.has(at)) {
|
|
1022
1122
|
try { resolvedFor.set(at, resolveParams(part, at.params)); } catch { resolvedFor.set(at, null); } // diagnosed by the build
|
|
1023
1123
|
}
|
|
@@ -1029,8 +1129,8 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1029
1129
|
if (!resolved) { delete sheetFrames[n]; continue; }
|
|
1030
1130
|
// For a views-map sub-part the export pose is the build itself (plus a sheet pose), so a canonical
|
|
1031
1131
|
// delivery's print frame is identity — no special case, the probe reads the compiled placement.
|
|
1032
|
-
if (
|
|
1033
|
-
if (
|
|
1132
|
+
if (needPrint) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved, frame });
|
|
1133
|
+
if (needBurn) {
|
|
1034
1134
|
const f = sheetFrameFor(part.parts[n], { ...resolved, frame });
|
|
1035
1135
|
if (f) sheetFrames[n] = f; else delete sheetFrames[n];
|
|
1036
1136
|
}
|
|
@@ -1038,10 +1138,26 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1038
1138
|
undrawnFrames.clear();
|
|
1039
1139
|
return { print: { ...printFrames }, sheet: { ...sheetFrames } };
|
|
1040
1140
|
}
|
|
1141
|
+
// A display change re-stales a delivered sub-part's frames when it changes
|
|
1142
|
+
// WHICH frames that piece needs (frameNeeds above); a `null` event (a clear)
|
|
1143
|
+
// re-checks every delivered name. computeFrames stays lazy (viewer.js
|
|
1144
|
+
// syncFrames), so a CAD-only session still computes nothing.
|
|
1145
|
+
cleanup.defer(viewer.onDisplayChange?.((evt) => {
|
|
1146
|
+
let stale = false;
|
|
1147
|
+
for (const n of evt?.name ? [evt.name] : deliveredAt.keys()) {
|
|
1148
|
+
if (!deliveredAt.has(n) || undrawnFrames.has(n)) continue; // nothing drawn yet, or already stale
|
|
1149
|
+
if (frameNeeds.get(n) === needsKey(n)) continue;
|
|
1150
|
+
undrawnFrames.add(n);
|
|
1151
|
+
stale = true;
|
|
1152
|
+
}
|
|
1153
|
+
if (stale) viewer.invalidateFrames?.();
|
|
1154
|
+
}) ?? (() => {}));
|
|
1041
1155
|
viewer.setFrameSource?.(computeFrames);
|
|
1042
1156
|
|
|
1043
1157
|
// Sub-parts whose latest fresh delivery had zero triangles (see the `meshes` case).
|
|
1044
1158
|
const emptySubParts = new Set();
|
|
1159
|
+
// Any live delivery has landed. A preview decoded after this is dropped unseen.
|
|
1160
|
+
let liveDelivered = false;
|
|
1045
1161
|
|
|
1046
1162
|
function refreshView() {
|
|
1047
1163
|
const needed = viewSubParts(part, view(), params);
|
|
@@ -1055,6 +1171,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1055
1171
|
} else if (needed.every((n) => viewer.hasSubMesh(n))) {
|
|
1056
1172
|
showView(needed); // stale but present — keep it visible during regenerate
|
|
1057
1173
|
ui.setExportEnabled(false);
|
|
1174
|
+
} else if (previewGate.isActive()) {
|
|
1175
|
+
// A cached preview that covers only part of the view: show what it has.
|
|
1176
|
+
showView(needed.filter((n) => viewer.hasSubMesh(n)));
|
|
1177
|
+
ui.setExportEnabled(false);
|
|
1058
1178
|
} else {
|
|
1059
1179
|
viewer.hideAssembly();
|
|
1060
1180
|
ui.setExportEnabled(false);
|
|
@@ -1086,6 +1206,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1086
1206
|
case "meshes": {
|
|
1087
1207
|
const fresh = loop.buildDone();
|
|
1088
1208
|
if (fresh) { // stale results (params changed mid-build) are discarded
|
|
1209
|
+
liveDelivered = true; // only a delivery that is applied beats a preview still decoding
|
|
1089
1210
|
for (const m of data.meshes) {
|
|
1090
1211
|
viewer.setSubGeometry(m.name, m); // disposes any previous mesh for this name
|
|
1091
1212
|
// The view its job was SENT for, and the frame the worker delivered
|
|
@@ -1112,6 +1233,10 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1112
1233
|
// forgetting on a disagreement would rebuild at the same params forever).
|
|
1113
1234
|
fastPath.apply(data.meshes.map((m) => m.name), undefined, { fresh: true });
|
|
1114
1235
|
recordFrames(data.meshes);
|
|
1236
|
+
// The whole view is live: every sub-part the preview drew has just been
|
|
1237
|
+
// replaced in place, so the tools it held back come back. Before
|
|
1238
|
+
// refreshView, so showView restores a carried cutaway on this build.
|
|
1239
|
+
if (previewGate.isActive() && missingParts().length === 0) previewGate.end();
|
|
1115
1240
|
// A split dispatch answers in two meshes replies; the busy spinner
|
|
1116
1241
|
// stays up until the view has everything (the other worker's job may
|
|
1117
1242
|
// still be running — often OCCT, the slow one).
|
|
@@ -1459,12 +1584,81 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1459
1584
|
return binding;
|
|
1460
1585
|
};
|
|
1461
1586
|
|
|
1587
|
+
// Set by the first cleanup to run at dispose (LIFO): late async work checks it.
|
|
1588
|
+
let disposed = false;
|
|
1589
|
+
cleanup.defer(() => { disposed = true; });
|
|
1590
|
+
|
|
1591
|
+
// The cached preview: a GLB from partforge/gltf, decoded off the worker path
|
|
1592
|
+
// and drawn through setSubGeometry like any delivery — so the first live
|
|
1593
|
+
// `meshes` reply replaces it in place, sub-part by sub-part, and showView
|
|
1594
|
+
// keeps the camera (the view was already framed). Only a file for the view on
|
|
1595
|
+
// screen is drawn, and only its sub-parts of that view; anything else — or a
|
|
1596
|
+
// file that will not decode — is logged once and ignored.
|
|
1597
|
+
if (preview != null) {
|
|
1598
|
+
// Loaded on demand: the GLTFLoader and meshopt decoder behind it stay out
|
|
1599
|
+
// of every bundle that never passes a preview.
|
|
1600
|
+
import("./gltf/decode-preview.js").then(({ decodePreviewGlb }) => (
|
|
1601
|
+
disposed ? null : decodePreviewGlb(preview)
|
|
1602
|
+
)).then((decoded) => {
|
|
1603
|
+
if (decoded == null) return;
|
|
1604
|
+
const { view: previewView, meshes } = decoded;
|
|
1605
|
+
if (disposed || liveDelivered) return; // live geometry always wins
|
|
1606
|
+
const current = view();
|
|
1607
|
+
if (previewView != null && previewView !== current) {
|
|
1608
|
+
console.warn(`partforge: preview ignored — it shows view "${previewView}", the viewer opened on "${current}"`);
|
|
1609
|
+
return;
|
|
1610
|
+
}
|
|
1611
|
+
const needed = viewSubParts(part, current, params);
|
|
1612
|
+
const shown = meshes.filter((m) => needed.includes(m.name));
|
|
1613
|
+
if (shown.length === 0) {
|
|
1614
|
+
console.warn("partforge: preview ignored — none of its meshes is a sub-part of the view on screen");
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
measureMode.setEnabled(false);
|
|
1618
|
+
annotateMode?.setEnabled(false);
|
|
1619
|
+
previewGate.start();
|
|
1620
|
+
for (const m of shown) viewer.setSubGeometry(m.name, m);
|
|
1621
|
+
refreshView();
|
|
1622
|
+
}).catch((error) => {
|
|
1623
|
+
if (!disposed) console.warn(`partforge: preview ignored — ${error?.message ?? error}`);
|
|
1624
|
+
});
|
|
1625
|
+
}
|
|
1626
|
+
|
|
1627
|
+
// runtime.exportPreview: the view on screen as a preview GLB — every sub-part's
|
|
1628
|
+
// drawn mesh with its fast-path pose baked in, the posed meshes a headless
|
|
1629
|
+
// buildView returns. Only the view on screen (no other view's meshes are held),
|
|
1630
|
+
// only when it is fully built at the live params (missingParts covers both a
|
|
1631
|
+
// first build and an edit still rebuilding), never a cached preview. The
|
|
1632
|
+
// encoder loads on first use: a host that never exports never pays for it.
|
|
1633
|
+
async function exportPreview({ view: wanted } = {}) {
|
|
1634
|
+
if (disposed) throw new Error("exportPreview: this mount is disposed");
|
|
1635
|
+
if (previewGate.isActive()) throw new Error("exportPreview: a cached preview is showing — wait for the first build");
|
|
1636
|
+
const current = view();
|
|
1637
|
+
if (wanted != null && wanted !== current) {
|
|
1638
|
+
throw new Error(`exportPreview: "${wanted}" is not the view on screen ("${current}")`);
|
|
1639
|
+
}
|
|
1640
|
+
const needed = viewSubParts(part, current, params);
|
|
1641
|
+
if (needed.length === 0 || missingParts().length > 0) {
|
|
1642
|
+
throw new Error("exportPreview: the view on screen is not fully built");
|
|
1643
|
+
}
|
|
1644
|
+
const meshes = needed.map((name) => viewer.getSubMeshPayload(name)).filter(Boolean);
|
|
1645
|
+
const { encodePreviewGlb } = await import("./gltf/encode-preview.js");
|
|
1646
|
+
return encodePreviewGlb({ meshes, part, view: current });
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1462
1649
|
return makeHandle({
|
|
1463
1650
|
ready, dispose, viewer, setParams,
|
|
1651
|
+
isPreviewActive: () => previewGate.isActive(),
|
|
1652
|
+
pendingCutaway: () => (!cutawayRestored ? viewerState?.cutaway ?? null : null),
|
|
1653
|
+
exportPreview,
|
|
1464
1654
|
attachTooltips: attachHostTooltips,
|
|
1465
1655
|
setHostPane: paneTabs.setHostPane,
|
|
1466
1656
|
setRailLayout: paneTabs.setRailLayout,
|
|
1467
1657
|
getView: view, // () => tabsCtl.current()
|
|
1658
|
+
// `opts` may be null from a host (a destructuring default covers only undefined).
|
|
1659
|
+
listSubParts: (opts) => subPartRows(part, { view: opts?.view ?? view(), params, overrides: viewer.getDisplayOverrides?.() ?? {} }),
|
|
1660
|
+
openAppearance: (name) => viewStyle.openCard?.(name) ?? false,
|
|
1661
|
+
setDisplaySaveState: (state) => viewStyle.setSaveState?.(state),
|
|
1468
1662
|
setView: (name) => tabsCtl.select(name),
|
|
1469
1663
|
captureView,
|
|
1470
1664
|
getPanelState: () => panelRef?.getState() ?? {},
|
|
@@ -1481,13 +1675,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, pick
|
|
|
1481
1675
|
animation: animCtl?.runtime ?? null,
|
|
1482
1676
|
measure: {
|
|
1483
1677
|
isEnabled: measureMode.isEnabled,
|
|
1484
|
-
|
|
1678
|
+
// Held off while a cached preview shows (no feature ids to dimension).
|
|
1679
|
+
setEnabled: (on) => { if (on && previewGate.isActive()) return; measureMode.setEnabled(on); },
|
|
1485
1680
|
clearPins: measureMode.clearPins,
|
|
1486
1681
|
pinCount: measureMode.pinCount,
|
|
1487
1682
|
},
|
|
1488
1683
|
annotate: annotateMode ? {
|
|
1489
1684
|
isEnabled: annotateMode.isEnabled,
|
|
1490
|
-
setEnabled: annotateMode.setEnabled,
|
|
1685
|
+
setEnabled: (on) => { if (on && previewGate.isActive()) return; annotateMode.setEnabled(on); },
|
|
1491
1686
|
undo: annotateMode.undo,
|
|
1492
1687
|
clear: annotateMode.clear,
|
|
1493
1688
|
strokeCount: annotateMode.strokeCount,
|