partforge 0.147.0 → 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.
@@ -13,7 +13,9 @@ import { loadEnvironmentRig } from "./materials/environment.js";
13
13
  import { assetUrl } from "./materials/assets.js";
14
14
  import { resolveEnvironmentId } from "./materials/resolve.js";
15
15
  import { burnsFor, realisticDisplay } from "./materials/sheet-look.js";
16
+ import { effectiveDisplay, mergeOverride, sanitizeDisplayPatch, subPartVisibility, APPEARANCE_OVERRIDE_KEYS, VIEWING_KEYS } from "./materials/overrides.js";
16
17
  import { neutralToneMapToSrgb8 } from "./materials/tonemap-readback.js";
18
+ import { createMaterialThumbCache, thumbDisplay } from "./materials/thumb-cache.js";
17
19
  import { createCutaway } from "./cutaway.js";
18
20
  import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
19
21
  import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
@@ -62,7 +64,7 @@ export function halfFloatCaptureSupported({ readable, readType = null, halfFloat
62
64
  // a GL context. The grid is hidden for the whole synchronous pass and restored.
63
65
  // `poseFor(view)`, when given, places each view's camera in place of
64
66
  // cameraPoseForView(view, bounds) — the viewer passes the cad style's fit.
65
- export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, sceneBounds, hidden = [], poseFor }) {
67
+ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, sceneBounds, hidden = [], reveal = [], poseFor }) {
66
68
  const views = (viewNames?.length ? viewNames : ["iso", "front", "top"])
67
69
  .filter((v) => CANONICAL_VIEWS.includes(v))
68
70
  .slice(0, CANONICAL_VIEWS.length);
@@ -71,6 +73,9 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
71
73
  if (grid) grid.visible = false;
72
74
  const hiddenWas = hidden.map((o) => o.visible);
73
75
  for (const o of hidden) o.visible = false;
76
+ // `reveal`: [{ show, restore }] — sub-parts the user hid or faded, shown as
77
+ // if untouched for THIS pass only (agent renders must never miss a piece).
78
+ for (const r of reveal) r.show();
74
79
  try {
75
80
  return views.map((view) => {
76
81
  const pose = poseFor ? poseFor(view) : cameraPoseForView(view, bounds);
@@ -81,6 +86,7 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
81
86
  return { view, dataUrl: renderer.renderOffscreen(pose, { sceneBounds }) };
82
87
  });
83
88
  } finally {
89
+ for (const r of reveal) r.restore();
84
90
  if (grid) grid.visible = gridWasVisible;
85
91
  hidden.forEach((o, i) => { o.visible = hiddenWas[i]; });
86
92
  liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
@@ -159,6 +165,11 @@ export function captureCurrentFromScene(
159
165
 
160
166
  export function createViewer(container, part) {
161
167
  const names = Object.keys(part.parts);
168
+ // Membership for host-supplied names. subMesh, cadMats and part.parts are
169
+ // plain objects, so "toString" or "constructor" would read as present on
170
+ // them; every name a host hands the override API is checked here instead.
171
+ const nameSet = new Set(names);
172
+ const has = (name) => nameSet.has(name);
162
173
 
163
174
  // --- renderer / scene / camera --------------------------------------------
164
175
  const renderer = new THREE.WebGLRenderer({ antialias: true, stencil: true });
@@ -265,11 +276,21 @@ export function createViewer(container, part) {
265
276
  const partsGroup = new THREE.Group();
266
277
  pivot.add(partsGroup);
267
278
 
268
- // Per-sub-part CAD material: the shared default unless the sub-part declares
269
- // appearance in `display` (colour, opacity, or a library material flattened
270
- // for the CAD view — materials/resolve.js cadAppearance).
279
+ // --- per-sub-part display overrides (materials/overrides.js) ----------------
280
+ // Appearance keys merge over the authored display for EVERY material read
281
+ // below (effectiveDisplayOf); the viewing keys (hidden, opacity) are read only
282
+ // by applySubOpacity / effectiveVisible. Carried by mount in viewerState.
283
+ const displayOverrides = new Map(); // name -> override (never empty)
284
+ const displayListeners = new Set();
285
+ const effectiveDisplayOf = (name) => (has(name) ? effectiveDisplay(part.parts[name]?.display, displayOverrides.get(name) ?? null) : {});
286
+ const isHidden = (name) => displayOverrides.get(name)?.hidden === true;
287
+ const userOpacityOf = (name) => displayOverrides.get(name)?.opacity; // undefined = none
288
+
289
+ // Per-sub-part CAD material: the shared default unless the EFFECTIVE display
290
+ // declares appearance (colour, opacity, or a library material flattened for
291
+ // the CAD view — materials/resolve.js cadAppearance).
271
292
  function materialFor(name) {
272
- return buildCadMaterial(part.parts[name].display, material);
293
+ return buildCadMaterial(effectiveDisplayOf(name), material);
273
294
  }
274
295
 
275
296
  const subMesh = Object.fromEntries(
@@ -315,6 +336,11 @@ export function createViewer(container, part) {
315
336
  let renderMode = "cad";
316
337
  let realisticRig = null;
317
338
  let shadowMovedAt = null; // performance.now() of a pose change the contact shadow has not caught up with
339
+ // The appearance card's sphere thumbnails (renderMaterialThumbnail). Up here,
340
+ // ahead of every look change that clears it (setTheme, enterRealistic, enterCad).
341
+ const materialThumbs = createMaterialThumbCache();
342
+ let _thumbSphere = null;
343
+
318
344
 
319
345
  // three calls scene.onBeforeRender(renderer, scene, camera, target) at the
320
346
  // very start of WebGLRenderer.render — after scene/camera matrixWorld are
@@ -337,7 +363,11 @@ export function createViewer(container, part) {
337
363
  if (renderMode === "realistic") realisticRig?.updateForCamera?.(camera);
338
364
  };
339
365
 
340
- const effectiveVisible = () => lastShown.filter((n) => (animOpacity.get(n) ?? 1) > 0);
366
+ // A sub-part the user hid, or faded to nothing (the user floor is above 0, so
367
+ // only the animation can), is out of the visible set: the cutaway, the
368
+ // ground, raycast and visible-bounds framing all follow.
369
+ const effectiveVisible = () => lastShown.filter((n) =>
370
+ !isHidden(n) && (animOpacity.get(n) ?? 1) > 0 && (userOpacityOf(n) ?? 1) > 0);
341
371
 
342
372
  // A fade clone is a material the cutaway does not own, so it has to be told
343
373
  // about the clipping plane explicitly — otherwise a mid-fade part renders
@@ -375,45 +405,48 @@ export function createViewer(container, part) {
375
405
  return m;
376
406
  }
377
407
 
378
- // Re-derive one sub-part's material + visibility from (shown, override).
408
+ // Re-derive one sub-part's material + visibility from (shown, animation
409
+ // opacity, the user's hide/see-through) through the one pure decision,
410
+ // materials/overrides.js subPartVisibility.
411
+ //
412
+ // Restore ONLY from our own fade clone. showAssembly runs on every regen
413
+ // (mount.js's refreshView) without disabling the cutaway, and an enabled
414
+ // cutaway has swapped these onto its clipped clones
415
+ // (createSectionRenderSet.setEnabled) — an unconditional write here would
416
+ // silently drop clipping on every sub-part on the next param edit.
417
+ // When we just took the mesh off our clone, an enabled cutaway must get the
418
+ // chance to re-claim it onto its clipped clone — the base material we write
419
+ // carries no plane, and nothing else would put it back until the next
420
+ // cutaway toggle or theme change. Guarded on hadFade so the every-regen
421
+ // showAssembly path stays a no-op for un-faded sub-parts.
379
422
  function applySubOpacity(name) {
423
+ if (!has(name)) return;
380
424
  const mesh = subMesh[name], lines = subLines[name];
381
425
  if (!mesh) return;
382
- const shown = lastShown.includes(name);
383
- const v = animOpacity.get(name);
384
- if (v === undefined) {
385
- // Restore ONLY from our own fade clone. showAssembly runs on every regen
386
- // (mount.js's refreshView) without disabling the cutaway, and an enabled
387
- // cutaway has swapped these onto its clipped clones
388
- // (createSectionRenderSet.setEnabled) — an unconditional write here would
389
- // silently drop clipping on every sub-part on the next param edit.
426
+ const { meshVisible, linesVisible, fade } = subPartVisibility({
427
+ shown: lastShown.includes(name),
428
+ anim: animOpacity.get(name),
429
+ user: userOpacityOf(name),
430
+ hidden: isHidden(name),
431
+ renderMode,
432
+ staticOpacity: effectiveDisplayOf(name).opacity ?? 1,
433
+ });
434
+ if (fade === null) {
390
435
  const hadFade = mesh.material === fadeMats.get(name);
391
436
  if (hadFade) mesh.material = baseMats[name];
392
437
  if (lines.material === fadeLineMats.get(name)) lines.material = lineMaterial;
393
- // We just took the mesh off our clone, so an enabled cutaway must get the
394
- // chance to re-claim it onto its clipped clone — the base material we
395
- // wrote above carries no plane, and nothing else would put it back until
396
- // the next cutaway toggle or theme change. Guarded on hadFade so the
397
- // every-regen showAssembly path stays a no-op for un-faded sub-parts.
398
438
  if (hadFade) cutaway.resyncSubpart(name);
399
- mesh.visible = shown;
400
- lines.visible = shown && renderMode === "cad";
401
- return;
402
- }
403
- if (v <= 0) {
404
- mesh.visible = false;
405
- lines.visible = false;
406
- return;
439
+ } else {
440
+ const fm = fadeMatFor(name);
441
+ fm.opacity = fade;
442
+ mesh.material = fm;
443
+ const flm = fadeLineMatFor(name);
444
+ const so = effectiveDisplayOf(name).opacity ?? 1;
445
+ flm.opacity = so > 0 ? fade / so : 0; // lines fade by the multiplier alone
446
+ lines.material = flm;
407
447
  }
408
- const staticOpacity = part.parts[name].display?.opacity ?? 1;
409
- const fm = fadeMatFor(name);
410
- fm.opacity = staticOpacity * v;
411
- mesh.material = fm;
412
- const flm = fadeLineMatFor(name);
413
- flm.opacity = v;
414
- lines.material = flm;
415
- mesh.visible = shown;
416
- lines.visible = shown && renderMode === "cad";
448
+ mesh.visible = meshVisible;
449
+ lines.visible = linesVisible && meshVisible;
417
450
  }
418
451
 
419
452
  function setSubPartOpacity(name, value) {
@@ -441,6 +474,91 @@ export function createViewer(container, part) {
441
474
  cutaway.setVisible(effectiveVisible());
442
475
  }
443
476
 
477
+ // --- display overrides: the API (spec A.2) -----------------------------------
478
+ function publishDisplay(evt) {
479
+ for (const cb of [...displayListeners]) {
480
+ try { cb(evt); } catch (e) { console.warn("partforge: display listener failed", e); }
481
+ }
482
+ }
483
+ // Re-point one sub-part at the materials its EFFECTIVE display now means, in
484
+ // whichever mode is live — no geometry work (rebaseSubMaterial's contract).
485
+ // A sub-part with no appearance shares the base `material` (buildCadMaterial
486
+ // returns it), which every other such sub-part is also on: never dispose it.
487
+ function rematerialize(name) {
488
+ if (cadMats[name] !== material) cadMats[name]?.dispose();
489
+ const cad = materialFor(name);
490
+ cadMats[name] = cad;
491
+ const phys = physicalMats.get(name);
492
+ if (phys) { phys.dispose(); physicalMats.delete(name); }
493
+ if (renderMode === "realistic") {
494
+ if (subCache[name] && physicalFor(name).userData.pfAnisotropic) ensureBoxUVs(subCache[name]);
495
+ rebaseSubMaterial(name, physicalFor(name));
496
+ } else {
497
+ rebaseSubMaterial(name, cad);
498
+ }
499
+ }
500
+ function visibilityChanged() {
501
+ matchGeneration++;
502
+ cutaway.setVisible(effectiveVisible());
503
+ if (realisticRig) { shadowMovedAt = performance.now(); placeGround(); }
504
+ }
505
+ // Shallow, key-by-key: effective displays hold only primitives.
506
+ function sameDisplay(a, b) {
507
+ const ka = Object.keys(a), kb = Object.keys(b);
508
+ return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && Object.is(a[k], b[k]));
509
+ }
510
+ function setSubPartDisplay(name, patch) {
511
+ if (!has(name) || !subMesh[name]) return null;
512
+ const before = displayOverrides.get(name) ?? null;
513
+ const clean = sanitizeDisplayPatch(patch);
514
+ // Nothing survived sanitizing: not a change, so nothing is rebuilt and no
515
+ // listener hears it (a host saving on onDisplayChange would checkpoint a
516
+ // no-op). The mount-realistic tests pin this through material identity.
517
+ if (!Object.keys(clean).length) return before ? { ...before } : null;
518
+ const effectiveBefore = effectiveDisplayOf(name);
519
+ const next = mergeOverride(before, clean);
520
+ if (next) displayOverrides.set(name, next); else displayOverrides.delete(name);
521
+ // An appearance key that lands on the value already drawn (the same
522
+ // material picked twice) rebuilds nothing.
523
+ const appearanceMoved = APPEARANCE_OVERRIDE_KEYS.some((k) => k in clean) && !sameDisplay(effectiveBefore, effectiveDisplayOf(name));
524
+ const viewingMoved = VIEWING_KEYS.some((k) => k in clean);
525
+ if (appearanceMoved) rematerialize(name); else if (viewingMoved) applySubOpacity(name);
526
+ if (viewingMoved) visibilityChanged();
527
+ requestRender();
528
+ publishDisplay({ name, override: next ? { ...next } : null });
529
+ return next ? { ...next } : null;
530
+ }
531
+ function getSubPartDisplayState(name) {
532
+ if (!has(name) || !subMesh[name]) return null;
533
+ const override = displayOverrides.get(name) ?? null;
534
+ return {
535
+ authored: part.parts[name].display ?? null,
536
+ override: override ? { ...override } : null,
537
+ effective: effectiveDisplayOf(name),
538
+ hidden: isHidden(name),
539
+ opacity: userOpacityOf(name) ?? 1,
540
+ };
541
+ }
542
+ const getDisplayOverrides = () => Object.fromEntries([...displayOverrides].map(([n, o]) => [n, { ...o }]));
543
+ // Clear the viewing keys, the appearance keys, or both — for one sub-part or all.
544
+ function clearSubPartDisplay({ viewing = false, appearance = false, name = null } = {}) {
545
+ const targets = name ? (has(name) && displayOverrides.has(name) ? [name] : []) : [...displayOverrides.keys()];
546
+ if (!targets.length) return;
547
+ for (const n of targets) {
548
+ const o = displayOverrides.get(n);
549
+ const patch = {};
550
+ if (viewing) for (const k of VIEWING_KEYS) if (k in o) patch[k] = null;
551
+ if (appearance) for (const k of APPEARANCE_OVERRIDE_KEYS) if (k in o) patch[k] = null;
552
+ const next = mergeOverride(o, patch);
553
+ if (next) displayOverrides.set(n, next); else displayOverrides.delete(n);
554
+ if (appearance) rematerialize(n); else applySubOpacity(n);
555
+ }
556
+ visibilityChanged();
557
+ requestRender();
558
+ publishDisplay(null);
559
+ }
560
+ function onDisplayChange(cb) { displayListeners.add(cb); return () => displayListeners.delete(cb); }
561
+
444
562
  // The cutaway plane lives in world space, so its initial/reset bounds must
445
563
  // include the pivot rotation and the per-view recentering transform —
446
564
  // mesh.matrixWorld carries both. Union each visible mesh's own
@@ -724,10 +842,13 @@ export function createViewer(container, part) {
724
842
  let m = physicalMats.get(name);
725
843
  if (!m) {
726
844
  const sp = part.parts[name];
727
- // `burn` is decided once per sub-part (sheet-look.js burnsFor): only a laser sheet in
845
+ // `burn` is decided per sub-part material (sheet-look.js burnsFor): only a laser sheet in
728
846
  // a wood compiles the burn pass, so every other program is exactly what it was.
729
847
  // realisticDisplay: a laser sheet with no material draws as its stock (sheet-look.js).
730
- m = cloneKeepsPattern(buildPhysicalMaterial(realisticDisplay(sp), { printFrame: printFrames[name], burn: burnsFor(sp), loadTexture }));
848
+ // Both read sp.display, so hand them a sub-part whose display is the EFFECTIVE one
849
+ // (authored + the user's appearance override).
850
+ const eff = { ...sp, display: effectiveDisplayOf(name) };
851
+ m = cloneKeepsPattern(buildPhysicalMaterial(realisticDisplay(eff), { printFrame: printFrames[name], burn: burnsFor(eff), loadTexture }));
731
852
  physicalMats.set(name, m);
732
853
  syncGrain(name);
733
854
  applySheetFrame(m, sheetFrames[name]);
@@ -776,9 +897,10 @@ export function createViewer(container, part) {
776
897
  }
777
898
  }
778
899
 
779
- // Ghosts (a static display opacity below 1) cast no shadow.
900
+ // Ghosts (a static display opacity below 1) cast no shadow — a USER fade
901
+ // (see-through) counts as a ghost too.
780
902
  const castersNow = () => names
781
- .filter((n) => subMesh[n].visible && (part.parts[n].display?.opacity ?? 1) >= 1)
903
+ .filter((n) => subMesh[n].visible && (effectiveDisplayOf(n).opacity ?? 1) >= 1 && userOpacityOf(n) === undefined)
782
904
  .map((n) => subMesh[n]);
783
905
  // `force` renders even while parked: a capture is offscreen work a parked
784
906
  // viewer still does, and it must not bake a stale (or absent) shadow.
@@ -882,6 +1004,9 @@ export function createViewer(container, part) {
882
1004
  if (live) applyPixelRatio("realistic");
883
1005
  // Hides the lines on every sub-part now that renderMode reads realistic.
884
1006
  refreshFeatureLines();
1007
+ // A capture's borrowed look (live: false) is put back at once: the
1008
+ // thumbnails still describe the live one.
1009
+ if (live) invalidateMaterialThumbnails();
885
1010
  } catch (e) {
886
1011
  try { enterCad({ live }); } catch (rollback) { console.warn("partforge: rolling back to CAD failed", rollback); }
887
1012
  throw e;
@@ -916,6 +1041,7 @@ export function createViewer(container, part) {
916
1041
  for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
917
1042
  if (live) attempt(() => applyPixelRatio("cad"));
918
1043
  attempt(refreshFeatureLines);
1044
+ if (live) invalidateMaterialThumbnails();
919
1045
  if (firstError) throw firstError;
920
1046
  }
921
1047
 
@@ -1020,6 +1146,7 @@ export function createViewer(container, part) {
1020
1146
  environmentChosen = true;
1021
1147
  if (next !== environmentId) announceEnvironment(next);
1022
1148
  if (renderMode === "realistic" || realisticPending) await setRenderMode("realistic");
1149
+ invalidateMaterialThumbnails();
1023
1150
  return environmentId;
1024
1151
  }
1025
1152
 
@@ -1371,7 +1498,7 @@ export function createViewer(container, part) {
1371
1498
  for (const n of names) {
1372
1499
  const mesh = subMesh[n];
1373
1500
  if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
1374
- if ((part.parts[n].display?.opacity ?? 1) < 1) continue; // a ghost is seen through
1501
+ if ((effectiveDisplayOf(n).opacity ?? 1) < 1 || userOpacityOf(n) !== undefined) continue; // a ghost (authored or user see-through) is seen through
1375
1502
  mesh.updateWorldMatrix(true, false);
1376
1503
  _matchProxy.geometry = mesh.geometry;
1377
1504
  _matchProxy.matrixWorld.copy(mesh.matrixWorld);
@@ -1788,6 +1915,7 @@ export function createViewer(container, part) {
1788
1915
  cutaway.setTheme(mode, t.line);
1789
1916
  reassertLiveFades(); // setTheme re-clones every section's materials and reassigns them
1790
1917
  currentTheme = THEME[mode] ? mode : "dark";
1918
+ invalidateMaterialThumbnails();
1791
1919
  for (const cb of [...themeListeners]) cb(currentTheme);
1792
1920
  }
1793
1921
 
@@ -1903,7 +2031,9 @@ export function createViewer(container, part) {
1903
2031
  // scene (renderMeshPayloads' thumbnails) is CAD whatever the live mode.
1904
2032
  // Without HDR readback it falls back to the 8-bit target and tone-maps the
1905
2033
  // clipped linear bytes: highlights clip, but it is still the realistic look.
1906
- const realistic = renderScene === scene && renderMode === "realistic";
2034
+ // A throwaway scene that carries an environment (renderMaterialThumbnail's
2035
+ // realistic sphere) is realistic too; renderMeshPayloads' never has one.
2036
+ const realistic = renderMode === "realistic" && (renderScene === scene || renderScene.environment != null);
1907
2037
  const hdr = realistic && hdrCaptureReadback();
1908
2038
  const cachedSize = !hdr && width === _rtSize && height === _rtSize;
1909
2039
  const rt = cachedSize
@@ -2034,6 +2164,70 @@ export function createViewer(container, part) {
2034
2164
  return () => canonicalCaptureHidden.delete(obj);
2035
2165
  }
2036
2166
 
2167
+ // Sub-parts the user hid or faded, as `reveal` entries: shown as if untouched
2168
+ // for one capture pass (agent renders must never miss a piece), then put back.
2169
+ // Built and shown INSIDE a borrowed look (captureIn), never around one: a look
2170
+ // swap re-runs applySubOpacity on every sub-part, which would re-hide them.
2171
+ //
2172
+ // Accepted limitation: a mesh taken off a USER fade clone for the pass sits on
2173
+ // baseMats[n], which carries no cutaway plane, so with the cutaway enabled that
2174
+ // one piece renders unclipped in that one capture. A hidden, unfaded mesh keeps
2175
+ // whatever material it was on (the cutaway's clipped clone included), the same
2176
+ // hadFade rule applySubOpacity follows. The ground and contact shadow are not
2177
+ // re-derived for the pass either: they stay as the live view placed them.
2178
+ function revealEntries() {
2179
+ const out = [];
2180
+ for (const n of names) {
2181
+ if (!lastShown.includes(n) || !subCache[n]) continue;
2182
+ if (!isHidden(n) && userOpacityOf(n) === undefined) continue;
2183
+ const mesh = subMesh[n], lines = subLines[n];
2184
+ const fm = fadeMats.get(n), flm = fadeLineMats.get(n);
2185
+ // The fade clones are shared with the live view, so their opacities are
2186
+ // part of what the pass must put back, beside the assignments.
2187
+ const was = {
2188
+ mat: mesh.material, lmat: lines.material, vis: mesh.visible, lvis: lines.visible,
2189
+ fmOpacity: fm?.opacity, flmOpacity: flm?.opacity,
2190
+ };
2191
+ out.push({
2192
+ object: mesh,
2193
+ show: () => {
2194
+ const staticOpacity = effectiveDisplayOf(n).opacity ?? 1;
2195
+ const { meshVisible, linesVisible, fade } = subPartVisibility({
2196
+ shown: true, anim: animOpacity.get(n), user: undefined, hidden: false, renderMode, staticOpacity,
2197
+ });
2198
+ if (fade === null) {
2199
+ if (mesh.material === fadeMats.get(n)) mesh.material = baseMats[n];
2200
+ if (lines.material === fadeLineMats.get(n)) lines.material = lineMaterial;
2201
+ } else {
2202
+ const f = fadeMatFor(n); f.opacity = fade; mesh.material = f;
2203
+ const fl = fadeLineMatFor(n); fl.opacity = staticOpacity > 0 ? fade / staticOpacity : 0; lines.material = fl;
2204
+ }
2205
+ mesh.visible = meshVisible; lines.visible = linesVisible && meshVisible;
2206
+ },
2207
+ restore: () => {
2208
+ const f = fadeMats.get(n), fl = fadeLineMats.get(n);
2209
+ if (f) f.opacity = was.fmOpacity ?? f.opacity;
2210
+ if (fl) fl.opacity = was.flmOpacity ?? fl.opacity;
2211
+ mesh.material = was.mat; lines.material = was.lmat; mesh.visible = was.vis; lines.visible = was.lvis;
2212
+ },
2213
+ });
2214
+ }
2215
+ return out;
2216
+ }
2217
+ // Run `fn` with the user's hidden/faded sub-parts revealed (when `on`), and
2218
+ // put them back after — even if `fn` throws.
2219
+ function withRevealed(on, fn) {
2220
+ const reveal = on ? revealEntries() : [];
2221
+ // Only entries whose show() ran (even partly, if it threw) are put back;
2222
+ // restore() writes back values recorded before show(), so it is safe either way.
2223
+ const shown = [];
2224
+ try {
2225
+ for (const r of reveal) { shown.push(r); r.show(); }
2226
+ return fn();
2227
+ } finally { for (const r of shown) r.restore(); }
2228
+ }
2229
+ const hasVisibleBounds = () => { const b = getVisibleWorldBounds(); return !!b && !b.isEmpty(); };
2230
+
2037
2231
  // Render the canonical camera angles offscreen, framed to whatever is visible,
2038
2232
  // without disturbing the user's live view. Returns [{ view, dataUrl }].
2039
2233
  //
@@ -2045,13 +2239,21 @@ export function createViewer(container, part) {
2045
2239
  // borrowing CAD for this capture draws them for free.
2046
2240
  function captureCanonicalViews(viewNames) {
2047
2241
  if (disposed) return [];
2048
- const box = getVisibleWorldBounds();
2049
- if (!box || box.isEmpty()) return []; // nothing to draw: don't swap the look for it
2242
+ // Asked with the hidden pieces revealed, or a part whose every sub-part the
2243
+ // user hid would return [] instead of its render.
2244
+ if (!withRevealed(true, hasVisibleBounds)) return []; // nothing to draw: don't swap the look for it
2050
2245
  return captureIn("cad", null, () => canonicalViewsInCurrentLook(viewNames));
2051
2246
  }
2052
2247
  // The canonical views in whatever look the scene has right now — the body
2053
2248
  // both captureCanonicalViews and renderViews wrap in captureIn.
2249
+ // The user's hidden and faded sub-parts are revealed for the whole body,
2250
+ // framing included: getVisibleWorldBounds reads mesh.visible before
2251
+ // captureViewsFromScene runs (whose own `reveal` option stays for a caller
2252
+ // that frames itself).
2054
2253
  function canonicalViewsInCurrentLook(viewNames) {
2254
+ return withRevealed(true, () => canonicalViewsRevealed(viewNames));
2255
+ }
2256
+ function canonicalViewsRevealed(viewNames) {
2055
2257
  const box = getVisibleWorldBounds();
2056
2258
  if (!box || box.isEmpty()) return [];
2057
2259
  const center = box.getCenter(new THREE.Vector3()).toArray();
@@ -2087,12 +2289,15 @@ export function createViewer(container, part) {
2087
2289
  // assets have ALREADY loaded — this call is synchronous and cannot wait for
2088
2290
  // them — and otherwise falls back to the live look; renderViews is the async
2089
2291
  // path that guarantees realistic.
2292
+ //
2293
+ // `includeHidden: true` (build sight) renders the user's hidden and faded
2294
+ // sub-parts as if untouched; omitted, the capture is what the user sees.
2090
2295
  function captureCurrent(opts) {
2091
2296
  if (disposed) return null;
2092
- const box = getVisibleWorldBounds();
2093
- if (!box || box.isEmpty()) return null;
2094
- const { renderMode: want, ...rest } = opts ?? {};
2095
- const capture = () => currentFramingInCurrentLook(rest);
2297
+ const { renderMode: want, includeHidden = false, ...rest } = opts ?? {};
2298
+ if (!withRevealed(includeHidden, hasVisibleBounds)) return null;
2299
+ // Revealed inside the capture, so inside any borrowed look (revealEntries).
2300
+ const capture = () => withRevealed(includeHidden, () => currentFramingInCurrentLook(rest));
2096
2301
  if (want === "cad") return captureIn("cad", null, capture);
2097
2302
  if (want === "realistic" && renderMode !== "realistic") {
2098
2303
  const rig = loadedRigs.get(environmentId);
@@ -2195,6 +2400,83 @@ export function createViewer(container, part) {
2195
2400
  }
2196
2401
  }
2197
2402
 
2403
+ // A sphere in `display`'s material under the current look, for the
2404
+ // appearance card's tiles. A THROWAWAY scene (never the live one): in CAD it
2405
+ // is the flattened CAD material under the capture lights; in realistic it is
2406
+ // the physical material lit by the live rig's envMap with its tone mapping,
2407
+ // in front of the rig's own backdrop (which is what a clear material shows
2408
+ // through — three's transmission pass draws the background, and with none
2409
+ // acrylic reads as black glass). The sphere slightly overfills the frame so
2410
+ // the tile's round crop shows sphere only, never the backdrop's corners.
2411
+ // Cached per (material, colour, environment, theme, mode); null on failure.
2412
+ // The environment in the key is the rig actually LIT: setEnvironment
2413
+ // announces the new id before its rig loads, and a sphere drawn in that
2414
+ // window under the old rig must not be cached as the new one.
2415
+ const thumbKey = (display) => materialThumbs.key(
2416
+ display, (renderMode === "realistic" && realisticRig?.id) || environmentId, currentTheme, renderMode);
2417
+ async function renderMaterialThumbnail(display, { size = 96 } = {}) {
2418
+ if (disposed) return null;
2419
+ const key = thumbKey(display);
2420
+ const hit = materialThumbs.get(key);
2421
+ if (hit !== undefined) return hit;
2422
+ // Built from the key's own fields only (the preset, tinted), never the
2423
+ // sub-part's finish overrides or opacity — see thumb-cache.js.
2424
+ const look = thumbDisplay(display);
2425
+ let mat = null;
2426
+ try {
2427
+ const tmp = new THREE.Scene();
2428
+ const rig = renderMode === "realistic" ? realisticRig : null;
2429
+ if (rig) {
2430
+ mat = buildPhysicalMaterial(look, { loadTexture });
2431
+ // A textured preset (oak) would otherwise draw untextured, and be cached so.
2432
+ await whenTexturesSettled();
2433
+ // The look moved while the maps loaded: this sphere is for a key nobody
2434
+ // shows any more (the card re-renders on the change anyway).
2435
+ if (disposed || rig !== realisticRig || key !== thumbKey(display)) {
2436
+ mat.dispose();
2437
+ return null;
2438
+ }
2439
+ tmp.environment = rig.envMap;
2440
+ tmp.environmentRotation.set(0, rig.rotationY ?? 0, 0);
2441
+ tmp.background = rig.background ?? null;
2442
+ tmp.backgroundBlurriness = rig.backgroundBlurriness ?? 0;
2443
+ tmp.backgroundIntensity = rig.backgroundIntensity ?? 1;
2444
+ tmp.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
2445
+ // A rig's own lights (an enclosure's overhead LED bar) belong to the
2446
+ // live scene; a fresh one of the same strength stands in from above.
2447
+ for (const l of rig.lights ?? []) {
2448
+ const d = new THREE.DirectionalLight(l.color, l.intensity);
2449
+ d.position.set(0.5, 4, 1);
2450
+ tmp.add(d, d.target);
2451
+ }
2452
+ } else {
2453
+ mat = buildCadMaterial(look, material);
2454
+ tmp.background = new THREE.Color(THEME[currentTheme].bg);
2455
+ tmp.add(createHemisphereLight());
2456
+ const { key: k, fill: f } = createCaptureLights();
2457
+ k.position.set(3, 4, 5); f.position.set(-4, 1, 5);
2458
+ tmp.add(k, k.target, f, f.target);
2459
+ }
2460
+ _thumbSphere = _thumbSphere ?? new THREE.SphereGeometry(1, 48, 32);
2461
+ tmp.add(new THREE.Mesh(_thumbSphere, mat));
2462
+ const url = renderOffscreen(
2463
+ { position: [0, 0, 3.2], up: [0, 1, 0], target: [0, 0, 0] },
2464
+ { width: size, height: size, fov: 35, quality: 0.85, sceneBounds: { center: [0, 0, 0], radius: 1.2 } },
2465
+ tmp,
2466
+ );
2467
+ materialThumbs.set(key, url);
2468
+ return url;
2469
+ } catch (e) {
2470
+ console.warn("partforge: material thumbnail failed", e);
2471
+ return null;
2472
+ } finally {
2473
+ // Never the shared CAD base (buildCadMaterial returns it for a display
2474
+ // with no appearance); the textures are loadTexture's cache, not ours.
2475
+ if (mat && mat !== material) mat.dispose();
2476
+ }
2477
+ }
2478
+ function invalidateMaterialThumbnails() { materialThumbs.clear(); }
2479
+
2198
2480
  // Agent-facing canonical renders in a chosen appearance, whatever the live
2199
2481
  // view shows. CAD is exactly captureCanonicalViews (which is always CAD).
2200
2482
  // Realistic waits for the current environment's rig and the capture's
@@ -2702,6 +2984,8 @@ export function createViewer(container, part) {
2702
2984
  assemblyListeners.clear();
2703
2985
  themeListeners.clear();
2704
2986
  projectionListeners.clear();
2987
+ displayListeners.clear();
2988
+ displayOverrides.clear();
2705
2989
  canonicalCaptureHidden.clear();
2706
2990
  camTween.cancel();
2707
2991
  controls.dispose();
@@ -2718,6 +3002,8 @@ export function createViewer(container, part) {
2718
3002
  lastAnchor = null;
2719
3003
  anchorListeners.clear();
2720
3004
  flashGeometry.dispose();
3005
+ _thumbSphere?.dispose();
3006
+ materialThumbs.clear();
2721
3007
  cutaway.dispose();
2722
3008
  for (const n of names) {
2723
3009
  const g = subCache[n];
@@ -2757,6 +3043,12 @@ export function createViewer(container, part) {
2757
3043
  setSubGeometry,
2758
3044
  setSubPose,
2759
3045
  setSubPartOpacity,
3046
+ setSubPartDisplay,
3047
+ getSubPartDisplayState,
3048
+ getDisplayOverrides,
3049
+ clearSubPartDisplay,
3050
+ onDisplayChange,
3051
+ effectiveDisplayOf,
2760
3052
  clearSubPartOpacities,
2761
3053
  hasSubMesh,
2762
3054
  subTriangles,
@@ -2767,6 +3059,8 @@ export function createViewer(container, part) {
2767
3059
  renderMeshPayloads,
2768
3060
  renderViews,
2769
3061
  renderStyleThumbnail,
3062
+ renderMaterialThumbnail,
3063
+ invalidateMaterialThumbnails,
2770
3064
  onFrame,
2771
3065
  onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
2772
3066
  tweenCameraTo,