partforge 0.121.1 → 0.123.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.
Files changed (63) hide show
  1. package/docs/AUTHORING-PARTS.md +164 -3
  2. package/docs/KERNEL-CONTRACT.md +10 -0
  3. package/package.json +3 -1
  4. package/src/app-materials.js +23 -0
  5. package/src/framework/app.css +16 -1
  6. package/src/framework/cutaway.js +12 -0
  7. package/src/framework/geometry/blend-surfaces.js +256 -0
  8. package/src/framework/geometry/creased-normals.js +121 -7
  9. package/src/framework/geometry/manifold-backend.js +56 -15
  10. package/src/framework/geometry/mesh-fillet.js +55 -5
  11. package/src/framework/geometry/threemf.js +32 -3
  12. package/src/framework/jobs.js +2 -1
  13. package/src/framework/lint/index.js +2 -1
  14. package/src/framework/lint/rules-materials.js +56 -0
  15. package/src/framework/materials/assets/SOURCES.md +90 -0
  16. package/src/framework/materials/assets/env-outdoor.jpg +0 -0
  17. package/src/framework/materials/assets/env-print-bed.jpg +0 -0
  18. package/src/framework/materials/assets/env-studio.jpg +0 -0
  19. package/src/framework/materials/assets/env-workshop.jpg +0 -0
  20. package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
  21. package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
  22. package/src/framework/materials/assets/ground-concrete.jpg +0 -0
  23. package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
  24. package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
  25. package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
  26. package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
  27. package/src/framework/materials/assets/ground-paper.jpg +0 -0
  28. package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
  29. package/src/framework/materials/assets/ground-pei.jpg +0 -0
  30. package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
  31. package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
  32. package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
  33. package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
  34. package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
  35. package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
  36. package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
  37. package/src/framework/materials/assets.js +39 -0
  38. package/src/framework/materials/contact-shadow.js +155 -0
  39. package/src/framework/materials/environment.js +157 -0
  40. package/src/framework/materials/environments.js +82 -0
  41. package/src/framework/materials/patterns.js +243 -0
  42. package/src/framework/materials/physical.js +88 -0
  43. package/src/framework/materials/presets.js +89 -0
  44. package/src/framework/materials/print-bed.js +169 -0
  45. package/src/framework/materials/print-frame.js +18 -0
  46. package/src/framework/materials/resolve.js +117 -0
  47. package/src/framework/materials/tonemap-readback.js +36 -0
  48. package/src/framework/materials/uv.js +42 -0
  49. package/src/framework/mount.js +121 -4
  50. package/src/framework/realistic-controls.js +78 -0
  51. package/src/framework/selection/feature-highlight.js +3 -0
  52. package/src/framework/view-state.js +27 -1
  53. package/src/framework/viewer.js +623 -34
  54. package/src/materials-worker.js +3 -0
  55. package/src/parts/bracket.js +2 -1
  56. package/src/parts/hinged-box.js +3 -1
  57. package/src/parts/material-swatches.js +40 -0
  58. package/src/parts/planter.js +2 -1
  59. package/src/parts/propeller.js +2 -1
  60. package/src/parts/scott-label.js +3 -2
  61. package/src/parts/screw.js +2 -1
  62. package/types/index.d.ts +48 -3
  63. package/types/part.d.ts +15 -2
@@ -4,6 +4,13 @@ import { toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
4
4
  import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
5
5
  import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
+ import { UltraHDRLoader } from "three/addons/loaders/UltraHDRLoader.js";
8
+ import { buildCadMaterial, buildPhysicalMaterial } from "./materials/physical.js";
9
+ import { ensureBoxUVs } from "./materials/uv.js";
10
+ import { loadEnvironmentRig } from "./materials/environment.js";
11
+ import { assetUrl } from "./materials/assets.js";
12
+ import { resolveEnvironmentId } from "./materials/resolve.js";
13
+ import { neutralToneMapToSrgb8 } from "./materials/tonemap-readback.js";
7
14
  import { createCutaway } from "./cutaway.js";
8
15
  import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
9
16
  import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
@@ -41,6 +48,31 @@ export function srgbEncodeInPlace(data) {
41
48
  return data;
42
49
  }
43
50
 
51
+ // Readback (a realistic capture's target) → linear floats: half-float bits,
52
+ // or linear bytes from the 8-bit fallback target.
53
+ function readbackToLinear(buf) {
54
+ const out = new Float32Array(buf.length);
55
+ if (buf instanceof Uint16Array) for (let i = 0; i < buf.length; i++) out[i] = THREE.DataUtils.fromHalfFloat(buf[i]);
56
+ else for (let i = 0; i < buf.length; i++) out[i] = buf[i] / 255;
57
+ return out;
58
+ }
59
+
60
+ // Can a realistic capture render HDR into a half-float target and read it
61
+ // back? Both halves fail SILENTLY otherwise: three's readRenderTargetPixels
62
+ // logs and returns when the type is not readable, and gl.readPixels with
63
+ // RGBA/HALF_FLOAT raises an unseen INVALID_OPERATION unless that is the
64
+ // implementation's read type — either way the buffer stays zero and the
65
+ // capture is a valid, all-black JPEG. `readable` is three's
66
+ // capabilities.textureTypeReadable(HalfFloatType) (needs a colour-buffer
67
+ // float extension, which is also what makes the target renderable);
68
+ // `readType` is the implementation read type for a bound, complete
69
+ // half-float framebuffer, or null when no GL context was there to ask (a
70
+ // failed probe passes something that is not `halfFloat`).
71
+ export function halfFloatCaptureSupported({ readable, readType = null, halfFloat = null }) {
72
+ if (readable !== true) return false;
73
+ return readType === null || readType === halfFloat;
74
+ }
75
+
44
76
  // Render a set of canonical views without disturbing the live camera/canvas.
45
77
  // `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
46
78
  // target → readback → JPEG data URL); injected so this is unit-testable without
@@ -219,16 +251,11 @@ export function createViewer(container, part) {
219
251
  const partsGroup = new THREE.Group();
220
252
  pivot.add(partsGroup);
221
253
 
222
- // Per-sub-part material: parts share the default material unless they declare
223
- // `display: { color?, opacity? }` (e.g. a reference/ghost part shown in a
224
- // distinct colour and/or semi-transparent so it reads as "not a printed part").
254
+ // Per-sub-part CAD material: the shared default unless the sub-part declares
255
+ // appearance in `display` (colour, opacity, or a library material flattened
256
+ // for the CAD view — materials/resolve.js cadAppearance).
225
257
  function materialFor(name) {
226
- const disp = part.parts[name].display;
227
- if (!disp || (disp.color == null && disp.opacity == null)) return material;
228
- const m = material.clone();
229
- if (disp.color != null) m.color = new THREE.Color(disp.color);
230
- if (disp.opacity != null && disp.opacity < 1) { m.transparent = true; m.opacity = disp.opacity; m.depthWrite = false; }
231
- return m;
258
+ return buildCadMaterial(part.parts[name].display, material);
232
259
  }
233
260
 
234
261
  const subMesh = Object.fromEntries(
@@ -259,10 +286,20 @@ export function createViewer(container, part) {
259
286
  // half of "fade a part in, then animate it into place".
260
287
  const animOpacity = new Map(); // name -> value in [0, 1)
261
288
  const baseMats = Object.fromEntries(names.map((n) => [n, subMesh[n].material]));
289
+ // baseMats is the CURRENT source material per sub-part, which realistic mode
290
+ // re-points at a physical material; cadMats keeps the CAD ones for the way
291
+ // back, and is what dispose() frees.
292
+ const cadMats = { ...baseMats };
262
293
  const fadeMats = new Map(); // name -> lazily cloned MeshStandardMaterial
263
294
  const fadeLineMats = new Map(); // name -> lazily cloned LineMaterial
264
295
  const fadeUnregisters = new Map(); // fade material -> its cutaway unregister fn
265
296
  let lastShown = []; // names last passed to showAssembly
297
+ // The render mode's LOOK, read by applySubOpacity (feature lines are CAD-only)
298
+ // and the depth range (the realistic ground disc). Declared here, ahead of
299
+ // everything that reads it; the mode itself lives in the realistic-mode block.
300
+ let renderMode = "cad";
301
+ let realisticRig = null;
302
+ let shadowMovedAt = null; // performance.now() of a pose change the contact shadow has not caught up with
266
303
 
267
304
  const effectiveVisible = () => lastShown.filter((n) => (animOpacity.get(n) ?? 1) > 0);
268
305
 
@@ -324,7 +361,7 @@ export function createViewer(container, part) {
324
361
  // every-regen showAssembly path stays a no-op for un-faded sub-parts.
325
362
  if (hadFade) cutaway.resyncSubpart(name);
326
363
  mesh.visible = shown;
327
- lines.visible = shown;
364
+ lines.visible = shown && renderMode === "cad";
328
365
  return;
329
366
  }
330
367
  if (v <= 0) {
@@ -340,7 +377,7 @@ export function createViewer(container, part) {
340
377
  flm.opacity = v;
341
378
  lines.material = flm;
342
379
  mesh.visible = shown;
343
- lines.visible = shown;
380
+ lines.visible = shown && renderMode === "cad";
344
381
  }
345
382
 
346
383
  function setSubPartOpacity(name, value) {
@@ -350,7 +387,10 @@ export function createViewer(container, part) {
350
387
  else animOpacity.set(name, Math.max(0, value));
351
388
  applySubOpacity(name);
352
389
  const isZero = (animOpacity.get(name) ?? 1) <= 0;
353
- if (wasZero !== isZero) cutaway.setVisible(effectiveVisible());
390
+ if (wasZero !== isZero) {
391
+ cutaway.setVisible(effectiveVisible());
392
+ if (realisticRig) shadowMovedAt = performance.now(); // a caster came or went
393
+ }
354
394
  }
355
395
 
356
396
  function clearSubPartOpacities() {
@@ -396,6 +436,7 @@ export function createViewer(container, part) {
396
436
  const _depthCenter = new THREE.Vector3();
397
437
  const _depthSize = new THREE.Vector3();
398
438
  const _gridCorner = new THREE.Vector3();
439
+ const _groundBounds = new THREE.Box3();
399
440
  function sceneDepthBounds({ withGrid } = {}) {
400
441
  // getVisibleWorldBounds returns a SHARED Box3 that the cutaway also reads,
401
442
  // so copy before touching it.
@@ -405,6 +446,10 @@ export function createViewer(container, part) {
405
446
  _depthBounds.expandByPoint(_gridCorner.set(-half, floorY, -half));
406
447
  _depthBounds.expandByPoint(_gridCorner.set(half, floorY, half));
407
448
  }
449
+ // The realistic ground disc is several part radii across, so it is the
450
+ // bigger half of the answer the way the grid is in CAD: without it the far
451
+ // plane cuts the disc's back edge off.
452
+ if (realisticRig) _depthBounds.union(_groundBounds.setFromObject(realisticRig.ground));
408
453
  if (_depthBounds.isEmpty()) return null;
409
454
  return {
410
455
  center: _depthBounds.getCenter(_depthCenter).toArray(),
@@ -447,6 +492,371 @@ export function createViewer(container, part) {
447
492
  cutaway.setSubpart(name, subMesh[name], subLines[name]);
448
493
  }
449
494
 
495
+ // --- realistic mode ---------------------------------------------------------
496
+ // "cad" is the drafting view (flat lights, grid, feature lines); "realistic"
497
+ // swaps every sub-part onto a physical material lit by an environment rig,
498
+ // with a ground and contact shadow brought to the part — which never moves.
499
+ //
500
+ // Materials change through ONE seam, rebaseSubMaterial: baseMats is what the
501
+ // fade clones and the cutaway's clipped clones are derived from, so the swap
502
+ // re-points that and lets both re-derive, rather than writing mesh.material
503
+ // (which the cutaway and the fades own) directly.
504
+ //
505
+ // Everything a load can fail at (the rig, the physical materials, the shader
506
+ // compile) happens BEFORE the live scene is touched, and the swap itself is
507
+ // one synchronous block that rolls back to CAD if it throws — so a failure at
508
+ // any point leaves a whole CAD (or the previous realistic) view, never a
509
+ // half-swapped one. Rigs are cached for the viewer's life and freed only in
510
+ // dispose(): scene.environment/background may still point into one.
511
+ let environmentId = resolveEnvironmentId(part.meta?.environment).id;
512
+ let modeToken = 0; // bumped by every request; a stale completion does nothing
513
+ let realisticPending = false; // a realistic request is in flight (setEnvironment joins it)
514
+ let environmentChosen = false; // set by setEnvironment; false while seeded from meta/default
515
+ const physicalMats = new Map(); // name -> MeshPhysicalMaterial (environment-independent, cached)
516
+ const rigCache = new Map(); // environment id -> Promise<Rig>
517
+ const loadedRigs = new Map(); // environment id -> Rig, once its load has landed (synchronous captures)
518
+ const textureCache = new Map(); // asset file name -> Texture
519
+ const textureLoader = new THREE.TextureLoader();
520
+ let pmrem = null;
521
+ let printFrames = {};
522
+ const modeListeners = new Set();
523
+ const envListeners = new Set();
524
+ const SHADOW_LOWRES_MS = 100; // at most one low-res shadow per this, while a part moves
525
+ const SHADOW_SETTLE_MS = 200; // …and one full-res render this long after the last move
526
+ let shadowLowResAt = -Infinity;
527
+ const isCoarsePointer = () => {
528
+ try { return typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches; } catch { return false; }
529
+ };
530
+
531
+ // Anisotropic filtering: the ground disc and the wood grain are seen at grazing
532
+ // angles, where plain trilinear mip selection blurs them into smeared blocks.
533
+ const loadTexture = (file) => {
534
+ let t = textureCache.get(file);
535
+ if (!t) {
536
+ t = textureLoader.load(assetUrl(file));
537
+ t.anisotropy = Math.min(8, renderer.capabilities?.getMaxAnisotropy?.() ?? 1);
538
+ textureCache.set(file, t);
539
+ }
540
+ return t;
541
+ };
542
+ const loadHdr = (url) => new UltraHDRLoader().setDataType(THREE.HalfFloatType).loadAsync(url);
543
+ function rigFor(id) {
544
+ if (!rigCache.has(id)) {
545
+ pmrem ??= new THREE.PMREMGenerator(renderer);
546
+ const p = loadEnvironmentRig(renderer, id, { loadHdr, loadTexture, pmrem });
547
+ // A failure is retryable, not memoized — but only drop THIS attempt.
548
+ p.then((rig) => { if (rigCache.get(id) === p) loadedRigs.set(id, rig); },
549
+ () => { if (rigCache.get(id) === p) rigCache.delete(id); });
550
+ rigCache.set(id, p);
551
+ }
552
+ return rigCache.get(id);
553
+ }
554
+
555
+ // three's Material.copy carries neither onBeforeCompile nor
556
+ // customProgramCacheKey, and it JSON-copies userData. For a patterned
557
+ // physical material (patterns.js) that means every clone the cutaway or a
558
+ // fade makes silently drops the pattern — and serializes the pattern texture's
559
+ // image through a canvas to do it. So a patterned material's clone shares the
560
+ // hooks and the uniforms object (one print frame drives them all) instead.
561
+ function cloneKeepsPattern(m) {
562
+ if (!Object.hasOwn(m, "onBeforeCompile")) return m;
563
+ m.clone = function clone() {
564
+ const uniforms = this.userData.patternUniforms;
565
+ delete this.userData.patternUniforms;
566
+ let c;
567
+ try { c = THREE.MeshPhysicalMaterial.prototype.clone.call(this); } finally {
568
+ if (uniforms) this.userData.patternUniforms = uniforms;
569
+ }
570
+ if (uniforms) c.userData.patternUniforms = uniforms;
571
+ c.onBeforeCompile = this.onBeforeCompile;
572
+ c.customProgramCacheKey = this.customProgramCacheKey;
573
+ c.clone = clone;
574
+ return c;
575
+ };
576
+ return m;
577
+ }
578
+ function physicalFor(name) {
579
+ let m = physicalMats.get(name);
580
+ if (!m) {
581
+ m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
582
+ physicalMats.set(name, m);
583
+ }
584
+ return m;
585
+ }
586
+
587
+ function publishMode(extra = {}) {
588
+ const evt = { mode: renderMode, busy: false, error: null, ...extra };
589
+ for (const cb of [...modeListeners]) {
590
+ try { cb(evt); } catch (e) { console.warn("partforge: render-mode listener failed", e); }
591
+ }
592
+ }
593
+
594
+ // Point one sub-part at a new source material. The fade clones were cloned
595
+ // from the old one, so they go (applySubOpacity re-clones on demand); the
596
+ // cutaway re-derives its clipped clone and reassigns mesh.material for its
597
+ // current state; then any live fade is re-asserted on top.
598
+ function rebaseSubMaterial(name, mat) {
599
+ for (const map of [fadeMats, fadeLineMats]) {
600
+ const clone = map.get(name);
601
+ if (!clone) continue;
602
+ fadeUnregisters.get(clone)?.();
603
+ fadeUnregisters.delete(clone);
604
+ clone.dispose();
605
+ map.delete(name);
606
+ }
607
+ baseMats[name] = mat;
608
+ cutaway.refreshSubpartMaterial(name, mat);
609
+ applySubOpacity(name);
610
+ }
611
+ function setSubMaterials(matFor) {
612
+ for (const n of names) {
613
+ const m = matFor(n);
614
+ if (baseMats[n] !== m) rebaseSubMaterial(n, m);
615
+ else applySubOpacity(n); // same material, but the lines follow the mode
616
+ }
617
+ }
618
+
619
+ // Ghosts (a static display opacity below 1) cast no shadow.
620
+ const castersNow = () => names
621
+ .filter((n) => subMesh[n].visible && (part.parts[n].display?.opacity ?? 1) >= 1)
622
+ .map((n) => subMesh[n]);
623
+ // `force` renders even while parked: a capture is offscreen work a parked
624
+ // viewer still does, and it must not bake a stale (or absent) shadow.
625
+ function renderShadow(opts, { force = false } = {}) {
626
+ if (!realisticRig || (!active && !force)) return;
627
+ try {
628
+ if (opts) realisticRig.shadow.render(scene, castersNow(), opts);
629
+ else realisticRig.shadow.render(scene, castersNow());
630
+ } catch (e) {
631
+ console.warn("partforge: contact shadow failed", e);
632
+ }
633
+ }
634
+ // The ground comes to the part: it sits at the bottom of what is visible and
635
+ // is sized from it. Only on showAssembly (and on entering the mode) — an
636
+ // animated pose change moves the shadow, never the floor.
637
+ function placeGround({ force = false } = {}) {
638
+ if (!realisticRig) return;
639
+ const b = getVisibleWorldBounds(); // shared Box3: read it out before anything else runs
640
+ if (b.isEmpty()) return;
641
+ const center = b.getCenter(new THREE.Vector3());
642
+ const size = b.getSize(new THREE.Vector3());
643
+ realisticRig.setGround({
644
+ y: b.min.y, centerX: center.x, centerZ: center.z, radius: size.length() / 2, footprintMm: Math.max(size.x, size.z),
645
+ });
646
+ shadowMovedAt = null; // this render is the full-resolution one
647
+ renderShadow(undefined, { force });
648
+ }
649
+ // Called once per rendered frame: low-res while a part is moving, one full
650
+ // render once it has settled, nothing while it is still.
651
+ function updateMovingShadow() {
652
+ if (!realisticRig || shadowMovedAt == null) return;
653
+ const t = performance.now();
654
+ if (t - shadowMovedAt >= SHADOW_SETTLE_MS) {
655
+ shadowMovedAt = null;
656
+ renderShadow();
657
+ } else if (t - shadowLowResAt >= SHADOW_LOWRES_MS) {
658
+ shadowLowResAt = t;
659
+ renderShadow({ lowRes: true });
660
+ }
661
+ }
662
+
663
+ const livePixelRatio = (mode) =>
664
+ Math.min(devicePixelRatio, mode === "realistic" && isCoarsePointer() ? 1.5 : 2);
665
+ function applyPixelRatio(mode) {
666
+ const want = livePixelRatio(mode);
667
+ if (renderer.getPixelRatio() === want) return;
668
+ renderer.setPixelRatio(want);
669
+ resize();
670
+ }
671
+
672
+ // The two synchronous halves of a mode change. `live: false` is for a
673
+ // capture that borrows a look for one synchronous render (it leaves the
674
+ // canvas's pixel ratio alone, and renders the shadow even while parked);
675
+ // nothing here publishes — setRenderMode does. `reground: false` puts a rig
676
+ // back exactly where it was (after a CAD capture borrowed the scene): the
677
+ // ground only moves on showAssembly, never because a capture happened.
678
+ function enterRealistic(rig, { live = true, reground = true } = {}) {
679
+ try {
680
+ renderMode = "realistic";
681
+ // A regen can land between the proxy compile and here, while the mode
682
+ // was still CAD, so setSubGeometry skipped the UVs. Idempotent.
683
+ for (const n of names) if (subCache[n] && physicalFor(n).userData.pfAnisotropic) ensureBoxUVs(subCache[n]);
684
+ setSubMaterials(physicalFor);
685
+ if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
686
+ realisticRig = rig;
687
+ scene.environment = rig.envMap;
688
+ scene.background = rig.background;
689
+ scene.backgroundBlurriness = rig.backgroundBlurriness;
690
+ scene.backgroundIntensity = rig.backgroundIntensity ?? 1;
691
+ scene.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
692
+ scene.environmentRotation.set(0, rig.rotationY ?? 0, 0);
693
+ scene.add(rig.ground, rig.shadow.group);
694
+ for (const l of Object.values(liveLights)) l.visible = false;
695
+ grid.visible = false;
696
+ renderer.toneMapping = THREE.NeutralToneMapping;
697
+ renderer.toneMappingExposure = rig.exposure;
698
+ if (reground) placeGround({ force: !live });
699
+ if (live) applyPixelRatio("realistic");
700
+ } catch (e) {
701
+ try { enterCad({ live }); } catch (rollback) { console.warn("partforge: rolling back to CAD failed", rollback); }
702
+ throw e;
703
+ }
704
+ }
705
+ // Every step is attempted even if an earlier one throws (the first error is
706
+ // rethrown at the end): this is the rollback path too, and a half-restored
707
+ // CAD view is exactly what it exists to prevent. Tone mapping goes first.
708
+ function enterCad({ live = true } = {}) {
709
+ let firstError = null;
710
+ const attempt = (fn) => { try { fn(); } catch (e) { firstError ??= e; } };
711
+ attempt(() => {
712
+ renderer.toneMapping = THREE.NoToneMapping;
713
+ renderer.toneMappingExposure = 1;
714
+ });
715
+ renderMode = "cad";
716
+ attempt(() => {
717
+ if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
718
+ });
719
+ realisticRig = null;
720
+ shadowMovedAt = null;
721
+ attempt(() => {
722
+ scene.environment = null;
723
+ scene.background = new THREE.Color(THEME[currentTheme].bg);
724
+ scene.backgroundBlurriness = 0;
725
+ scene.backgroundIntensity = 1;
726
+ scene.backgroundRotation.set(0, 0, 0);
727
+ scene.environmentRotation.set(0, 0, 0);
728
+ });
729
+ attempt(() => { for (const l of Object.values(liveLights)) l.visible = true; });
730
+ attempt(() => { grid.visible = true; });
731
+ for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
732
+ if (live) attempt(() => applyPixelRatio("cad"));
733
+ if (firstError) throw firstError;
734
+ }
735
+
736
+ const isStale = (token) => disposed || token !== modeToken;
737
+
738
+ // Compile every built sub-part's physical program against the rig before the
739
+ // look is swapped in, so the swap does not stall on shader compilation.
740
+ // Three keys a program on tone mapping and output colour space, and both
741
+ // differ with a render target bound, so the canvas variant compiles under
742
+ // the realistic tone mapping and a capture's variant with a render
743
+ // target bound — each only for the synchronous compile() inside
744
+ // compileAsync, so the view on screen is untouched while it waits.
745
+ function compileRealistic(rig, { forCapture = false } = {}) {
746
+ const proxy = new THREE.Scene();
747
+ proxy.environment = rig.envMap;
748
+ for (const n of names) {
749
+ const m = physicalFor(n);
750
+ const geo = subCache[n];
751
+ if (!geo) continue; // not built yet: it compiles when it is first shown
752
+ if (m.userData.pfAnisotropic) ensureBoxUVs(geo);
753
+ proxy.add(new THREE.Mesh(geo, m));
754
+ }
755
+ const toneMappingBefore = renderer.toneMapping;
756
+ // Any bound target gives the capture's key (NoToneMapping, linear output);
757
+ // an 8-bit one is always renderable.
758
+ const probe = forCapture ? new THREE.WebGLRenderTarget(1, 1) : null;
759
+ if (probe) renderer.setRenderTarget(probe);
760
+ else renderer.toneMapping = THREE.NeutralToneMapping;
761
+ let compiling;
762
+ try { compiling = renderer.compileAsync?.(proxy, activeCamera); } finally {
763
+ renderer.toneMapping = toneMappingBefore;
764
+ if (probe) renderer.setRenderTarget(null);
765
+ }
766
+ return Promise.resolve(compiling).finally(() => probe?.dispose());
767
+ }
768
+
769
+ // Resolves to the mode actually in effect. A later setRenderMode or
770
+ // setEnvironment supersedes this one; a superseded completion changes
771
+ // nothing (its rig and materials are cached, so there is nothing to undo).
772
+ async function setRenderMode(next) {
773
+ if (disposed) return renderMode;
774
+ const want = next === "realistic" ? "realistic" : "cad";
775
+ const token = ++modeToken;
776
+ if (want === "cad") {
777
+ realisticPending = false;
778
+ if (renderMode !== "cad") {
779
+ try { enterCad(); } catch (e) { console.warn("partforge: leaving the realistic view failed", e); }
780
+ }
781
+ publishMode();
782
+ return renderMode;
783
+ }
784
+ const envId = environmentId;
785
+ if (renderMode === "realistic" && realisticRig?.id === envId) {
786
+ realisticPending = false;
787
+ publishMode();
788
+ return renderMode;
789
+ }
790
+ const wasRealistic = renderMode === "realistic";
791
+ realisticPending = true;
792
+ publishMode({ busy: true });
793
+ try {
794
+ const rig = await rigFor(envId);
795
+ if (isStale(token)) return renderMode;
796
+ await compileRealistic(rig);
797
+ if (isStale(token)) return renderMode;
798
+ enterRealistic(rig);
799
+ realisticPending = false;
800
+ publishMode();
801
+ } catch (e) {
802
+ if (isStale(token)) return renderMode;
803
+ realisticPending = false;
804
+ console.warn("partforge: the realistic view failed to load", e);
805
+ // Still realistic means the previous rig is still what's on screen: the
806
+ // environment the switch asked for never landed, so the id (and anyone
807
+ // showing it) goes back to the one actually lit.
808
+ if (renderMode === "realistic" && realisticRig && realisticRig.id !== environmentId) {
809
+ announceEnvironment(realisticRig.id);
810
+ }
811
+ publishMode({ error: wasRealistic && renderMode === "realistic" ? "couldn't load that environment" : "couldn't load realistic view" });
812
+ }
813
+ return renderMode;
814
+ }
815
+
816
+ function announceEnvironment(id) {
817
+ environmentId = id;
818
+ for (const cb of [...envListeners]) {
819
+ try { cb(id); } catch (e) { console.warn("partforge: environment listener failed", e); }
820
+ }
821
+ }
822
+
823
+ // The id is recorded (and announced) at once; in realistic mode — or on the
824
+ // way into it — the new rig then loads and replaces the current one. If that
825
+ // load fails while realistic, setRenderMode reverts the id to the rig still
826
+ // shown and announces that too; in CAD nothing loads, so the id just stands.
827
+ async function setEnvironment(id) {
828
+ const next = resolveEnvironmentId(id).id;
829
+ environmentChosen = true;
830
+ if (next !== environmentId) announceEnvironment(next);
831
+ if (renderMode === "realistic" || realisticPending) await setRenderMode("realistic");
832
+ return environmentId;
833
+ }
834
+
835
+ // Per-sub-part print frames (display → export) for the layer-line pattern.
836
+ function setPrintFrames(frames) {
837
+ printFrames = frames ?? {};
838
+ const identity = new THREE.Matrix4().toArray();
839
+ for (const [n, m] of physicalMats) m.userData.patternUniforms?.pfPrintFrame.value.fromArray(printFrames[n] ?? identity);
840
+ }
841
+
842
+ // Resolves once the current environment's rig is loaded (captures wait on it).
843
+ const whenRealisticReady = () => rigFor(environmentId).then(() => {});
844
+
845
+ function disposeRealistic() {
846
+ modeToken++;
847
+ modeListeners.clear();
848
+ envListeners.clear();
849
+ for (const p of rigCache.values()) p.then((r) => r.dispose(), () => {});
850
+ rigCache.clear();
851
+ loadedRigs.clear();
852
+ for (const m of physicalMats.values()) m.dispose();
853
+ physicalMats.clear();
854
+ for (const t of textureCache.values()) t.dispose();
855
+ textureCache.clear();
856
+ pmrem?.dispose();
857
+ pmrem = null;
858
+ }
859
+
450
860
  // --- animation hooks --------------------------------------------------------
451
861
  // Frame listeners get dt (seconds, clamped so a background-tab return doesn't
452
862
  // fast-forward playback) inside the render loop — so a parked viewer
@@ -730,6 +1140,8 @@ export function createViewer(container, part) {
730
1140
  setSubPose(name, null); // fresh worker mesh is baked at current params — clear any fast-path pose
731
1141
  const prev = subCache[name];
732
1142
  const next = buildGeometry(payload);
1143
+ // Brushed metal needs UVs for its tangent frame; CAD meshes carry none.
1144
+ if (renderMode === "realistic" && physicalMats.get(name)?.userData.pfAnisotropic) ensureBoxUVs(next);
733
1145
  subCache[name] = next;
734
1146
  // Section helpers must stop referring to the old buffers before those
735
1147
  // buffers are released.
@@ -749,6 +1161,9 @@ export function createViewer(container, part) {
749
1161
  else obj.matrix.identity();
750
1162
  obj.matrixWorldNeedsUpdate = true;
751
1163
  }
1164
+ // The contact shadow follows on the render loop's throttle (see
1165
+ // updateMovingShadow); the ground itself stays put until showAssembly.
1166
+ if (realisticRig) shadowMovedAt = performance.now();
752
1167
  }
753
1168
 
754
1169
  // Cache queries for the app's regenerate loop (so it never reaches into subCache).
@@ -802,6 +1217,7 @@ export function createViewer(container, part) {
802
1217
  }
803
1218
  if (frame) frameTo(visibleNames);
804
1219
  cutaway.setVisible(effectiveVisible());
1220
+ placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
805
1221
  }
806
1222
 
807
1223
  // Re-frame whatever is currently visible (the reframe button).
@@ -840,10 +1256,13 @@ export function createViewer(container, part) {
840
1256
  // Swap the scene background, grid, and edge-line colors for the given theme.
841
1257
  function setTheme(mode) {
842
1258
  const t = THEME[mode] ?? THEME.dark;
843
- scene.background = new THREE.Color(t.bg);
1259
+ // Realistic mode's backdrop belongs to the environment and its grid is off;
1260
+ // enterCad puts the theme's background back on the way out.
1261
+ if (renderMode === "cad") scene.background = new THREE.Color(t.bg);
844
1262
  scene.remove(grid);
845
1263
  grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, t.grid[0], t.grid[1]);
846
1264
  grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
1265
+ grid.visible = renderMode === "cad";
847
1266
  scene.add(grid);
848
1267
  lineMaterial.color.set(t.line);
849
1268
  for (const m of fadeLineMats.values()) m.color.set(t.line); // clones follow the theme
@@ -908,6 +1327,45 @@ export function createViewer(container, part) {
908
1327
  // the mask silently no-ops and every cap floods its whole plane with hatch —
909
1328
  // no error, live view unaffected, wrong only in the capture.
910
1329
  const RT_OPTIONS = { samples: 4, stencilBuffer: true };
1330
+ // Decided once per viewer, on the first realistic capture (see
1331
+ // halfFloatCaptureSupported). Two 1×1 probes, because a capture needs two
1332
+ // things of half-float: RENDERING into the exact target a capture uses (4×
1333
+ // MSAA with a stencil — a GPU can support single-sampled half-float and
1334
+ // still refuse a multisampled one, which would come back as a black capture),
1335
+ // and READING BACK, which three does from the resolved single-sampled
1336
+ // framebuffer, so the read format is asked of a plain half-float target.
1337
+ let _hdrReadback = null;
1338
+ function hdrCaptureReadback() {
1339
+ if (_hdrReadback !== null) return _hdrReadback;
1340
+ const readable = renderer.capabilities?.textureTypeReadable?.(THREE.HalfFloatType) === true;
1341
+ let readType = null, halfFloat = null;
1342
+ const gl = renderer.getContext?.();
1343
+ if (readable && typeof gl?.getParameter === "function") {
1344
+ halfFloat = gl.HALF_FLOAT;
1345
+ const complete = () => gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
1346
+ const renderProbe = new THREE.WebGLRenderTarget(1, 1, { ...RT_OPTIONS, type: THREE.HalfFloatType });
1347
+ const readProbe = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType });
1348
+ try {
1349
+ renderer.setRenderTarget(renderProbe);
1350
+ const renderable = complete();
1351
+ renderer.setRenderTarget(readProbe);
1352
+ readType = renderable && complete() && gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_FORMAT) === gl.RGBA
1353
+ ? gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_TYPE)
1354
+ : -1;
1355
+ } catch {
1356
+ readType = -1;
1357
+ } finally {
1358
+ renderer.setRenderTarget(null);
1359
+ renderProbe.dispose();
1360
+ readProbe.dispose();
1361
+ }
1362
+ }
1363
+ _hdrReadback = halfFloatCaptureSupported({ readable, readType, halfFloat });
1364
+ if (!_hdrReadback) {
1365
+ console.warn("partforge: this device can't read back HDR renders; realistic captures are tone-mapped from 8-bit (highlights clip)");
1366
+ }
1367
+ return _hdrReadback;
1368
+ }
911
1369
  //
912
1370
  // `viewOffset` ({ fullWidth, fullHeight, x, y }) renders the width×height
913
1371
  // output as that sub-window of a larger virtual frame — the recentred
@@ -918,10 +1376,18 @@ export function createViewer(container, part) {
918
1376
  { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9,
919
1377
  projection = "perspective", orthoHalfH = 1, viewOffset, sceneBounds } = {},
920
1378
  renderScene = scene) {
921
- const cachedSize = width === _rtSize && height === _rtSize;
1379
+ // A realistic LIVE scene renders HDR into a half-float target and is tone
1380
+ // mapped here (three tone-maps only the canvas path). It is lit by its
1381
+ // environment alone, so the CAD capture lights stay out of it. A throwaway
1382
+ // scene (renderMeshPayloads' thumbnails) is CAD whatever the live mode.
1383
+ // Without HDR readback it falls back to the 8-bit target and tone-maps the
1384
+ // clipped linear bytes: highlights clip, but it is still the realistic look.
1385
+ const realistic = renderScene === scene && renderMode === "realistic";
1386
+ const hdr = realistic && hdrCaptureReadback();
1387
+ const cachedSize = !hdr && width === _rtSize && height === _rtSize;
922
1388
  const rt = cachedSize
923
1389
  ? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
924
- : new THREE.WebGLRenderTarget(width, height, RT_OPTIONS);
1390
+ : new THREE.WebGLRenderTarget(width, height, hdr ? { ...RT_OPTIONS, type: THREE.HalfFloatType } : RT_OPTIONS);
925
1391
  _capLights = _capLights ?? createCaptureLights();
926
1392
  // Canonical captures never pass `projection`, so agent-facing renders and
927
1393
  // the CLI stay perspective no matter what the user is looking at. Built
@@ -936,18 +1402,22 @@ export function createViewer(container, part) {
936
1402
  });
937
1403
  if (viewOffset) cam.setViewOffset(viewOffset.fullWidth, viewOffset.fullHeight, viewOffset.x, viewOffset.y, width, height);
938
1404
  const { position, up, target } = pose;
939
- const buf = new Uint8Array(width * height * 4);
1405
+ const buf = hdr ? new Uint16Array(width * height * 4) : new Uint8Array(width * height * 4);
940
1406
  // Swap the world-fixed key/fill for the camera-relative pair, for this one render
941
1407
  // only. A DirectionalLight aims at its `target`, whose matrixWorld only updates
942
1408
  // while it is in the scene graph, so both go in and both come back out.
943
- const poses = captureLightPoses({ position, up, target });
944
1409
  const { key: capKey, fill: capFill } = _capLights;
945
- capKey.position.set(poses.key[0], poses.key[1], poses.key[2]);
946
- capFill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
947
- for (const light of [capKey, capFill]) light.target.position.set(target[0], target[1], target[2]);
948
- liveLights.key.visible = false;
949
- liveLights.fill.visible = false;
950
- scene.add(capKey, capKey.target, capFill, capFill.target);
1410
+ // Restored to what they WERE, not to true: realistic mode keeps them off.
1411
+ const keyWas = liveLights.key.visible, fillWas = liveLights.fill.visible;
1412
+ if (!realistic) {
1413
+ const poses = captureLightPoses({ position, up, target });
1414
+ capKey.position.set(poses.key[0], poses.key[1], poses.key[2]);
1415
+ capFill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
1416
+ for (const light of [capKey, capFill]) light.target.position.set(target[0], target[1], target[2]);
1417
+ liveLights.key.visible = false;
1418
+ liveLights.fill.visible = false;
1419
+ scene.add(capKey, capKey.target, capFill, capFill.target);
1420
+ }
951
1421
  // A pick marker is transient UI feedback about a click, never part of the
952
1422
  // part, so it belongs in no capture. It used to be near enough true that a
953
1423
  // capture would miss one — a dot faded after 1200ms — but a HELD dot lives
@@ -957,8 +1427,12 @@ export function createViewer(container, part) {
957
1427
  // than via canonicalCaptureHidden: captureCurrent deliberately ignores that
958
1428
  // set. Only dots that were actually shown are restored, so this cannot
959
1429
  // resurrect one the live canvas had hidden for its own reasons.
1430
+ // The pointer's hover highlight (selection/feature-highlight.js) is the
1431
+ // same kind of transient feedback, and it TINTS the surface under it: a
1432
+ // capture taken with the pointer resting on a part baked a translucent
1433
+ // blue wash over it (an orange PLA sample came out pink).
960
1434
  const reshowFlashDots = [];
961
- for (const dot of flashDots) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
1435
+ for (const dot of [...flashDots, ...captureHidden]) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
962
1436
  try {
963
1437
  renderer.setRenderTarget(rt);
964
1438
  renderer.render(renderScene, cam);
@@ -969,9 +1443,11 @@ export function createViewer(container, part) {
969
1443
  // Never leave the user's own view unlit, pointed at the offscreen target,
970
1444
  // or missing a marker the user is still looking at.
971
1445
  renderer.setRenderTarget(null);
972
- scene.remove(capKey, capKey.target, capFill, capFill.target);
973
- liveLights.key.visible = true;
974
- liveLights.fill.visible = true;
1446
+ if (!realistic) {
1447
+ scene.remove(capKey, capKey.target, capFill, capFill.target);
1448
+ liveLights.key.visible = keyWas;
1449
+ liveLights.fill.visible = fillWas;
1450
+ }
975
1451
  for (const dot of reshowFlashDots) dot.visible = true;
976
1452
  if (!cachedSize) rt.dispose();
977
1453
  }
@@ -979,16 +1455,26 @@ export function createViewer(container, part) {
979
1455
  canvas.width = width; canvas.height = height;
980
1456
  const ctx = canvas.getContext("2d");
981
1457
  const img = ctx.createImageData(width, height);
1458
+ // Linear pixels → sRGB bytes: tone-mapped for a realistic capture, only
1459
+ // transfer-encoded for CAD (which renders LDR with no tone mapping).
1460
+ const encoded = realistic ? neutralToneMapToSrgb8(readbackToLinear(buf), renderer.toneMappingExposure) : srgbEncodeInPlace(buf);
982
1461
  // flip rows (GL origin is bottom-left)
983
1462
  for (let y = 0; y < height; y++) {
984
1463
  const src = (height - 1 - y) * width * 4;
985
- img.data.set(buf.subarray(src, src + width * 4), y * width * 4);
1464
+ img.data.set(encoded.subarray(src, src + width * 4), y * width * 4);
986
1465
  }
987
- srgbEncodeInPlace(img.data);
988
1466
  ctx.putImageData(img, 0, 0);
989
1467
  return canvas.toDataURL("image/jpeg", quality);
990
1468
  }
991
1469
 
1470
+ // Pointer feedback excluded from EVERY offscreen render (renderOffscreen),
1471
+ // showcase captures included — unlike canonicalCaptureHidden below.
1472
+ const captureHidden = new Set();
1473
+ function registerCaptureHidden(obj) {
1474
+ captureHidden.add(obj);
1475
+ return () => captureHidden.delete(obj);
1476
+ }
1477
+
992
1478
  // Objects excluded from CANONICAL captures only (agent renders must stay
993
1479
  // dimension-free); captureCurrent — the user-framed showcase capture —
994
1480
  // deliberately does NOT consult this set.
@@ -1000,8 +1486,21 @@ export function createViewer(container, part) {
1000
1486
 
1001
1487
  // Render the canonical camera angles offscreen, framed to whatever is visible,
1002
1488
  // without disturbing the user's live view. Returns [{ view, dataUrl }].
1489
+ //
1490
+ // Always the CAD look, whatever the live view shows: these are the agent's
1491
+ // renders, and the editor's on-screen mode must never change what the agent
1492
+ // sees. A realistic live view lends its scene to CAD for the synchronous
1493
+ // capture and gets it back exactly (captureIn). renderViews is the one way
1494
+ // to ask for realistic canonical views.
1003
1495
  function captureCanonicalViews(viewNames) {
1004
1496
  if (disposed) return [];
1497
+ const box = getVisibleWorldBounds();
1498
+ if (!box || box.isEmpty()) return []; // nothing to draw: don't swap the look for it
1499
+ return captureIn("cad", null, () => canonicalViewsInCurrentLook(viewNames));
1500
+ }
1501
+ // The canonical views in whatever look the scene has right now — the body
1502
+ // both captureCanonicalViews and renderViews wrap in captureIn.
1503
+ function canonicalViewsInCurrentLook(viewNames) {
1005
1504
  const box = getVisibleWorldBounds();
1006
1505
  if (!box || box.isEmpty()) return [];
1007
1506
  const center = box.getCenter(new THREE.Vector3()).toArray();
@@ -1017,18 +1516,37 @@ export function createViewer(container, part) {
1017
1516
  bounds: { center, radius },
1018
1517
  // The ENCLOSING radius, which is a different number from the framing one
1019
1518
  // above: a box's corners reach √3 further than half its max extent, and
1020
- // the depth planes have to clear the corners.
1021
- sceneBounds: { center, radius: size.length() / 2 || 10 },
1519
+ // the depth planes have to clear the corners. A realistic capture keeps
1520
+ // its ground disc, which is several part radii across, so it counts too.
1521
+ sceneBounds: (realisticRig && sceneDepthBounds()) || { center, radius: size.length() / 2 || 10 },
1022
1522
  });
1023
1523
  }
1024
1524
 
1025
1525
  // One offscreen render of the user's CURRENT framing (live camera pose +
1026
1526
  // orbit target, live aspect) at a caller-chosen resolution — the showcase
1027
1527
  // capture. Returns a JPEG data URL, or null when disposed / nothing visible.
1528
+ //
1529
+ // `renderMode` picks the look. Omitted, it follows the live view (a gallery
1530
+ // "Capture from viewer" captures what the user sees). "cad" borrows CAD for
1531
+ // the synchronous capture if the live view is realistic. "realistic" from a
1532
+ // CAD view borrows the realistic look only if the current environment's
1533
+ // assets have ALREADY loaded — this call is synchronous and cannot wait for
1534
+ // them — and otherwise falls back to the live look; renderViews is the async
1535
+ // path that guarantees realistic.
1028
1536
  function captureCurrent(opts) {
1029
1537
  if (disposed) return null;
1030
1538
  const box = getVisibleWorldBounds();
1031
1539
  if (!box || box.isEmpty()) return null;
1540
+ const { renderMode: want, ...rest } = opts ?? {};
1541
+ const capture = () => currentFramingInCurrentLook(rest);
1542
+ if (want === "cad") return captureIn("cad", null, capture);
1543
+ if (want === "realistic" && renderMode !== "realistic") {
1544
+ const rig = loadedRigs.get(environmentId);
1545
+ if (rig) return captureIn("realistic", rig, capture);
1546
+ }
1547
+ return capture();
1548
+ }
1549
+ function currentFramingInCurrentLook(opts) {
1032
1550
  return captureCurrentFromScene(opts, {
1033
1551
  renderer: { renderOffscreen },
1034
1552
  liveCamera: activeCamera,
@@ -1049,6 +1567,53 @@ export function createViewer(container, part) {
1049
1567
  });
1050
1568
  }
1051
1569
 
1570
+ // Run one synchronous capture in the `want` look, leaving the live view
1571
+ // exactly as it was — even if the capture throws. The swap and the restore
1572
+ // sit in one synchronous block, so the canvas never paints a borrowed frame,
1573
+ // and nothing is published: the live mode never changed. A parked viewer
1574
+ // still captures (offscreen work), so its shadow is rendered on demand.
1575
+ function captureIn(want, rig, capture) {
1576
+ if (want === renderMode) {
1577
+ if (want === "realistic" && !active) renderShadow(undefined, { force: true });
1578
+ return capture();
1579
+ }
1580
+ if (want === "realistic") {
1581
+ enterRealistic(rig, { live: false }); // rolls itself back to CAD if it throws
1582
+ try { return capture(); } finally { enterCad({ live: false }); }
1583
+ }
1584
+ const liveRig = realisticRig, movedAt = shadowMovedAt;
1585
+ try {
1586
+ enterCad({ live: false });
1587
+ return capture();
1588
+ } finally {
1589
+ try {
1590
+ enterRealistic(liveRig, { live: false, reground: false });
1591
+ shadowMovedAt = movedAt;
1592
+ } catch (e) {
1593
+ // enterRealistic already rolled the scene back to CAD: make it the live mode.
1594
+ console.warn("partforge: restoring the realistic view after a capture failed", e);
1595
+ try { applyPixelRatio("cad"); } catch { /* the view is CAD either way */ }
1596
+ publishMode({ error: "couldn't load realistic view" });
1597
+ }
1598
+ }
1599
+ }
1600
+
1601
+ // Agent-facing canonical renders in a chosen appearance, whatever the live
1602
+ // view shows. CAD is exactly captureCanonicalViews (which is always CAD).
1603
+ // Realistic waits for the current environment's rig and the capture's
1604
+ // shader programs, then borrows the realistic look for the synchronous
1605
+ // capture only if the live view is not already realistic. A rig that fails
1606
+ // to load rejects rather than silently returning CAD images.
1607
+ async function renderViews(viewNames, { renderMode: want = "cad" } = {}) {
1608
+ if (disposed) return [];
1609
+ if (want !== "realistic") return captureCanonicalViews(viewNames);
1610
+ const rig = await rigFor(environmentId);
1611
+ if (disposed) return [];
1612
+ await compileRealistic(rig, { forCapture: true });
1613
+ if (disposed) return [];
1614
+ return captureIn("realistic", rig, () => canonicalViewsInCurrentLook(viewNames));
1615
+ }
1616
+
1052
1617
  // Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
1053
1618
  // Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
1054
1619
  // canonical angle, renders through the parameterized renderOffscreen, and disposes
@@ -1153,6 +1718,7 @@ export function createViewer(container, part) {
1153
1718
  for (const cb of [...frameListeners]) {
1154
1719
  try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
1155
1720
  }
1721
+ updateMovingShadow(); // after the listeners, so it sees this frame's poses
1156
1722
  // After the frame listeners, before anything reads the camera to draw with:
1157
1723
  // a playback frame may have moved sub-parts or the camera itself, and both
1158
1724
  // change where the planes belong.
@@ -1206,6 +1772,9 @@ export function createViewer(container, part) {
1206
1772
  return;
1207
1773
  }
1208
1774
  resize(); // rebuild the buffer at whatever size the container is now
1775
+ // A parked viewer renders no contact shadow (placeGround skips it), so a
1776
+ // regen while hidden left it stale.
1777
+ placeGround();
1209
1778
  lastFrameTime = null; // parked time is not elapsed time — no dt jump on unpark
1210
1779
  renderer.setAnimationLoop(renderFrame);
1211
1780
  }
@@ -1409,9 +1978,11 @@ export function createViewer(container, part) {
1409
1978
  for (const n of names) {
1410
1979
  const g = subCache[n];
1411
1980
  if (g) { g.userData.edges?.dispose(); g.dispose(); subCache[n] = null; }
1412
- // baseMats[n], not subMesh[n].material: an active fade override has swapped
1413
- // the mesh onto a clone, and the base material would otherwise leak.
1414
- baseMats[n]?.dispose();
1981
+ // cadMats[n], not subMesh[n].material: an active fade override has swapped
1982
+ // the mesh onto a clone, and the base material would otherwise leak. Not
1983
+ // baseMats[n] either — in realistic mode that is a physical material,
1984
+ // freed with the rest of them in disposeRealistic below.
1985
+ cadMats[n]?.dispose();
1415
1986
  subMesh[n].geometry?.dispose(); // the initial empty BufferGeometry, if never replaced
1416
1987
  }
1417
1988
  // Hand the fade clones back before freeing them. cutaway.dispose() above has
@@ -1425,6 +1996,7 @@ export function createViewer(container, part) {
1425
1996
  for (const m of fadeLineMats.values()) m.dispose();
1426
1997
  fadeMats.clear();
1427
1998
  fadeLineMats.clear();
1999
+ disposeRealistic();
1428
2000
  material.dispose();
1429
2001
  lineMaterial.dispose();
1430
2002
  grid.geometry.dispose();
@@ -1447,6 +2019,7 @@ export function createViewer(container, part) {
1447
2019
  captureCanonicalViews,
1448
2020
  captureCurrent,
1449
2021
  renderMeshPayloads,
2022
+ renderViews,
1450
2023
  onFrame,
1451
2024
  tweenCameraTo,
1452
2025
  cancelCameraTween,
@@ -1489,7 +2062,23 @@ export function createViewer(container, part) {
1489
2062
  getCutawayPlane: cutaway.getPlane,
1490
2063
  registerCutawayMaterial: cutaway.registerClippableMaterial,
1491
2064
  registerCanonicalCaptureHidden,
2065
+ registerCaptureHidden,
1492
2066
  onCutawayHandleHover: cutaway.onHandleHoverChange,
2067
+ setRenderMode,
2068
+ getRenderMode: () => renderMode,
2069
+ onRenderModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
2070
+ setEnvironment,
2071
+ getEnvironment: () => environmentId,
2072
+ // Whether the environment was named through setEnvironment (by the user or
2073
+ // a host) rather than seeded from the part's meta.environment / the
2074
+ // default — so a carried viewer state only pins an environment someone chose.
2075
+ isEnvironmentChosen: () => environmentChosen,
2076
+ // A realistic request is loading (getRenderMode still reads the mode on
2077
+ // screen until it lands); false once it lands, fails, or is superseded.
2078
+ isRealisticPending: () => realisticPending,
2079
+ onEnvironmentChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
2080
+ setPrintFrames,
2081
+ whenRealisticReady,
1493
2082
  dispose,
1494
2083
  };
1495
2084
  }