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.
@@ -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 { stageClearFor } from "./stage-clear.js";
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: viewer.getCutawayState?.() ?? null,
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.141.0: a place()-able sub-part is delivered in its
246
- // canonical frame and posed by the viewer; pose-only edits never rebuild):
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 and per-style feature-line preferences
384
- // // where that mount
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
- const viewbarEl = els.viewer.querySelector("#viewbar");
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
- if (viewerState?.cutaway?.enabled && viewer.setCutawayState?.(viewerState.cutaway)) {
969
- cutawayChrome.sync(); // the button was not what turned it on
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
- const layerLined = new Set(Object.keys(part.parts).filter((n) => {
996
- try { return resolveMaterial(realisticDisplay(part.parts[n])).params.pattern === "layer-lines"; } catch { return false; }
997
- }));
998
- const burning = new Set(Object.keys(part.parts).filter((n) => burnsFor(part.parts[n])));
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 undrawnFrames = new Map(); // sub-part -> { at: { view, params } its delivered mesh was built at, frame }
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 wanted = meshes.filter((m) => layerLined.has(m.name) || burning.has(m.name));
1012
- if (!wanted.length) return;
1013
- for (const m of wanted) {
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 [n, { at, frame }] of undrawnFrames) {
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 (layerLined.has(n)) printFrames[n] = printFrameMatrix(part.parts[n], { view: at.view, ...resolved, frame });
1033
- if (burning.has(n)) {
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
- setEnabled: measureMode.setEnabled,
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,