partforge 0.123.0 → 0.125.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.
@@ -6,6 +6,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { UltraHDRLoader } from "three/addons/loaders/UltraHDRLoader.js";
8
8
  import { buildCadMaterial, buildPhysicalMaterial } from "./materials/physical.js";
9
+ import { grainAxisFor, setGrainAxis } from "./materials/patterns.js";
9
10
  import { ensureBoxUVs } from "./materials/uv.js";
10
11
  import { loadEnvironmentRig } from "./materials/environment.js";
11
12
  import { assetUrl } from "./materials/assets.js";
@@ -16,7 +17,7 @@ import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
16
17
  import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
17
18
  import { createCameraTween } from "./camera-tween.js";
18
19
  import { orbitPose } from "./camera-orbit.js";
19
- import { orthoFrustum, perspectiveDistance } from "./projection.js";
20
+ import { orthoFrustum, perspectiveDistance, isFaceAligned, FACE_ALIGN_COS } from "./projection.js";
20
21
  import { depthRangeFor } from "./depth-range.js";
21
22
  import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
22
23
  import { makeCaptureCamera, recenteredView, captureDepthRange } from "./capture-frame.js";
@@ -154,7 +155,7 @@ export function captureCurrentFromScene(
154
155
  const long = Math.min(Math.max(Math.round(size) || MIN_SIZE, MIN_SIZE), maxTextureSize ?? 2048);
155
156
  // An OrthographicCamera has no `aspect` — its aspect lives in the frustum. Read
156
157
  // it there, or the capture comes back SQUARE from a wide viewport the moment the
157
- // user toggles to ortho: silent, and only wrong in the saved image.
158
+ // view goes ortho: silent, and only wrong in the saved image.
158
159
  const aspect = liveCamera.aspect
159
160
  || (liveCamera.isOrthographicCamera
160
161
  ? (liveCamera.right - liveCamera.left) / (liveCamera.top - liveCamera.bottom)
@@ -210,7 +211,7 @@ export function createViewer(container, part) {
210
211
 
211
212
  // Two cameras, one active. The perspective camera stays the source of truth
212
213
  // for fov and aspect; the ortho camera borrows both through projection.js so
213
- // a toggle never changes the part's size on screen.
214
+ // a swap never changes the part's size on screen.
214
215
  const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
215
216
  camera.position.set(18, 12, 18);
216
217
  const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
@@ -218,6 +219,15 @@ export function createViewer(container, part) {
218
219
  let activeCamera = camera;
219
220
  let projectionMode = "perspective";
220
221
  const projectionListeners = new Set();
222
+ // The view direction (target -> camera) orthographic was entered on. The
223
+ // projection is AUTOMATIC (Fusion 360's "Perspective with Ortho Faces"): a
224
+ // view cube face click settles into ortho, and the first frame the direction
225
+ // has turned off this axis — a rotation, never a pan or zoom — swaps back to
226
+ // perspective. Non-null exactly while ortho is live. See checkAutoProjection.
227
+ let orthoAxis = null;
228
+ // Bumped by every change to what a ray down the view axis could hit — see
229
+ // sizeMatchDepth. Declared up here because the geometry setters bump it.
230
+ let matchGeneration = 0;
221
231
 
222
232
  const controls = new OrbitControls(activeCamera, renderer.domElement);
223
233
  controls.enableDamping = true;
@@ -294,13 +304,35 @@ export function createViewer(container, part) {
294
304
  const fadeLineMats = new Map(); // name -> lazily cloned LineMaterial
295
305
  const fadeUnregisters = new Map(); // fade material -> its cutaway unregister fn
296
306
  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.
307
+ // The render mode's LOOK, read by applySubOpacity (feature lines are drawn
308
+ // only in "cad") and the depth range (the realistic ground disc). Declared
309
+ // here, ahead of everything that reads it; the mode itself lives in the
310
+ // realistic-mode block.
300
311
  let renderMode = "cad";
301
312
  let realisticRig = null;
302
313
  let shadowMovedAt = null; // performance.now() of a pose change the contact shadow has not caught up with
303
314
 
315
+ // three calls scene.onBeforeRender(renderer, scene, camera, target) at the
316
+ // very start of WebGLRenderer.render — after scene/camera matrixWorld are
317
+ // current, before objects are projected — so visibility set here applies to
318
+ // THAT render with the camera actually doing it: the live canvas, an
319
+ // offscreen capture (renderOffscreen reuses this same `scene`), and the
320
+ // contact shadow's own depth pass (which renders `scene` from below with
321
+ // its own camera, hiding every non-caster and restoring them in `finally`).
322
+ // That restore is not exact for the print bed: if the bed group was already
323
+ // hidden going in, the hook can set it visible for the shadow camera and
324
+ // leave it that way after the pass. Harmless — the next render's hook
325
+ // recomputes the flag for ITS camera before anything is drawn, and the
326
+ // plate lies outside the shadow camera's frustum, so the depth pass never
327
+ // draws it either way. No-op outside realistic mode or for a rig
328
+ // whose environment has nothing to hide (the ground discs already cull by
329
+ // their material's own side).
330
+ const priorSceneOnBeforeRender = scene.onBeforeRender;
331
+ scene.onBeforeRender = (r, s, camera, target) => {
332
+ priorSceneOnBeforeRender?.(r, s, camera, target);
333
+ if (renderMode === "realistic") realisticRig?.updateForCamera?.(camera);
334
+ };
335
+
304
336
  const effectiveVisible = () => lastShown.filter((n) => (animOpacity.get(n) ?? 1) > 0);
305
337
 
306
338
  // A fade clone is a material the cutaway does not own, so it has to be told
@@ -388,6 +420,9 @@ export function createViewer(container, part) {
388
420
  applySubOpacity(name);
389
421
  const isZero = (animOpacity.get(name) ?? 1) <= 0;
390
422
  if (wasZero !== isZero) {
423
+ // A part faded to (or back from) nothing is a visibility change like
424
+ // showAssembly's, so the remembered size-match surface asks afresh.
425
+ matchGeneration++;
391
426
  cutaway.setVisible(effectiveVisible());
392
427
  if (realisticRig) shadowMovedAt = performance.now(); // a caster came or went
393
428
  }
@@ -398,6 +433,7 @@ export function createViewer(container, part) {
398
433
  const touched = [...animOpacity.keys()];
399
434
  animOpacity.clear();
400
435
  for (const n of touched) applySubOpacity(n);
436
+ matchGeneration++; // any part that was faded out is visible again
401
437
  cutaway.setVisible(effectiveVisible());
402
438
  }
403
439
 
@@ -512,13 +548,22 @@ export function createViewer(container, part) {
512
548
  let modeToken = 0; // bumped by every request; a stale completion does nothing
513
549
  let realisticPending = false; // a realistic request is in flight (setEnvironment joins it)
514
550
  let environmentChosen = false; // set by setEnvironment; false while seeded from meta/default
551
+
552
+ // Feature lines are CAD-only: applySubOpacity reads the render mode
553
+ // directly, so this just re-applies it to every sub-part after a mode
554
+ // change (enterRealistic/enterCad both call it once they've set renderMode).
555
+ function refreshFeatureLines() { for (const n of names) applySubOpacity(n); }
556
+
515
557
  const physicalMats = new Map(); // name -> MeshPhysicalMaterial (environment-independent, cached)
516
558
  const rigCache = new Map(); // environment id -> Promise<Rig>
517
559
  const loadedRigs = new Map(); // environment id -> Rig, once its load has landed (synchronous captures)
560
+ const borrowedRigRefs = new Map(); // environment id -> count of in-flight captures (thumbnails, renderViews) borrowing it
518
561
  const textureCache = new Map(); // asset file name -> Texture
519
562
  const textureLoader = new THREE.TextureLoader();
520
563
  let pmrem = null;
521
564
  let printFrames = {};
565
+ let printFrameSource = null; // () => frames; mount's lazy provider (setPrintFrameSource)
566
+ let printFramesStale = false; // the source has frames the materials have not seen
522
567
  const modeListeners = new Set();
523
568
  const envListeners = new Set();
524
569
  const SHADOW_LOWRES_MS = 100; // at most one low-res shadow per this, while a part moves
@@ -530,15 +575,44 @@ export function createViewer(container, part) {
530
575
 
531
576
  // Anisotropic filtering: the ground disc and the wood grain are seen at grazing
532
577
  // angles, where plain trilinear mip selection blurs them into smeared blocks.
578
+ //
579
+ // TextureLoader.load returns at once and fills `image` later, so a rig (or a
580
+ // physical material) is "ready" long before its maps are: a capture taken
581
+ // straight away draws the floor and the wood untextured. Each load therefore
582
+ // records a settle promise (resolved on load AND on error — a missing map is
583
+ // drawn without it, never an error), and whenTexturesSettled() waits for them.
584
+ const pendingTextures = new Set();
533
585
  const loadTexture = (file) => {
534
586
  let t = textureCache.get(file);
535
587
  if (!t) {
536
- t = textureLoader.load(assetUrl(file));
588
+ let settle;
589
+ const settled = new Promise((resolve) => { settle = resolve; });
590
+ pendingTextures.add(settled);
591
+ settled.then(() => pendingTextures.delete(settled));
592
+ t = textureLoader.load(assetUrl(file), () => settle(), undefined, () => settle());
537
593
  t.anisotropy = Math.min(8, renderer.capabilities?.getMaxAnisotropy?.() ?? 1);
538
594
  textureCache.set(file, t);
539
595
  }
540
596
  return t;
541
597
  };
598
+ // Every texture requested so far has loaded or failed. Loops, because waiting
599
+ // can overlap more requests (a material built by a later compile). Bounded:
600
+ // an image request that never answers must not hang a mode switch or a
601
+ // capture forever — past the cap it draws with whatever has arrived.
602
+ const TEXTURE_SETTLE_CAP_MS = 15000;
603
+ async function whenTexturesSettled() {
604
+ const deadline = Date.now() + TEXTURE_SETTLE_CAP_MS;
605
+ while (pendingTextures.size) {
606
+ const left = deadline - Date.now();
607
+ if (left <= 0) return;
608
+ let timer;
609
+ await Promise.race([
610
+ Promise.all([...pendingTextures]),
611
+ new Promise((resolve) => { timer = setTimeout(resolve, left); }),
612
+ ]);
613
+ clearTimeout(timer);
614
+ }
615
+ }
542
616
  const loadHdr = (url) => new UltraHDRLoader().setDataType(THREE.HalfFloatType).loadAsync(url);
543
617
  function rigFor(id) {
544
618
  if (!rigCache.has(id)) {
@@ -552,6 +626,52 @@ export function createViewer(container, part) {
552
626
  return rigCache.get(id);
553
627
  }
554
628
 
629
+ // A rig loaded only to draw a capture (a thumbnail, or renderViews from CAD) is freed straight away: each holds
630
+ // its equirect (~16 MB of half-float) and a PMREM, and four of them resident
631
+ // on a phone is not acceptable. The live one (or one a switch is loading) stays
632
+ // — and only those: in CAD, the default environment is freed like any other.
633
+ // rig.dispose() also disposes the ground textures loadTexture cached — safe:
634
+ // each environment.js ground.texture/roughnessTexture/normalTexture is a
635
+ // distinct file (environments.js), so no two rigs ever share a Texture
636
+ // object, and Texture.dispose() only frees the GPU handle (via the
637
+ // renderer's 'dispose' listener) — it leaves texture.image and every field
638
+ // three's uploader reads untouched. loadTexture keeps returning that same
639
+ // (still-intact) Texture object out of textureCache, so a later rigFor for
640
+ // this id just re-uploads it on the next render, exactly like a first load.
641
+ //
642
+ // Concurrent captures of the same environment share one in-flight rig
643
+ // promise (rigFor's own cache), and a rig's envMap is a PMREM render-target
644
+ // texture that CANNOT re-upload once disposed — so releasing while a
645
+ // sibling call is still capturing with it would leave that capture's
646
+ // reflections black. EVERY async capture that awaits a rig must therefore
647
+ // borrow it through borrowRig and give it back through releaseRig in a
648
+ // `finally` (renderStyleThumbnail and renderViews both do). `p` is the
649
+ // exact promise borrowRig took from rigFor: `borrowedRigRefs` counts
650
+ // in-flight users of `id` (bumped synchronously in borrowRig, BEFORE any
651
+ // await, so an overlapping borrower is always already counted; decremented
652
+ // here), and this only deletes/
653
+ // disposes once that count reaches 0 AND `rigCache.get(id) === p` — the
654
+ // second check is what stops a call from freeing a *different* rig a later
655
+ // load (or the live environment) has since put in the cache for the same id.
656
+ function borrowRig(id) {
657
+ const p = rigFor(id);
658
+ borrowedRigRefs.set(id, (borrowedRigRefs.get(id) ?? 0) + 1);
659
+ return p;
660
+ }
661
+ function releaseRig(id, p) {
662
+ const refs = (borrowedRigRefs.get(id) ?? 0) - 1;
663
+ if (refs > 0) { borrowedRigRefs.set(id, refs); return; }
664
+ borrowedRigRefs.delete(id);
665
+ // Keep only a rig that is (or is about to be) on screen. In CAD the
666
+ // current environment's id alone is no reason: nothing is showing it, and
667
+ // a realistic switch reloads it (from the texture cache) when asked.
668
+ if (realisticRig?.id === id || (realisticPending && id === environmentId)) return;
669
+ if (rigCache.get(id) !== p) return;
670
+ rigCache.delete(id);
671
+ loadedRigs.delete(id);
672
+ p.then((r) => r.dispose(), () => {});
673
+ }
674
+
555
675
  // three's Material.copy carries neither onBeforeCompile nor
556
676
  // customProgramCacheKey, and it JSON-copies userData. For a patterned
557
677
  // physical material (patterns.js) that means every clone the cutaway or a
@@ -580,9 +700,19 @@ export function createViewer(container, part) {
580
700
  if (!m) {
581
701
  m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
582
702
  physicalMats.set(name, m);
703
+ syncGrain(name);
583
704
  }
584
705
  return m;
585
706
  }
707
+ // Wood grain runs along the sub-part's longest axis (patterns.js). The
708
+ // uniforms are shared with every clone, so setting them once reaches the
709
+ // cutaway's and the fades' copies too. Called when the material is built and
710
+ // whenever new geometry lands (a regen can change which axis is longest).
711
+ function syncGrain(name) {
712
+ const m = physicalMats.get(name);
713
+ const geo = subCache[name];
714
+ if (m && geo) setGrainAxis(m, grainAxisFor(geo.boundingBox));
715
+ }
586
716
 
587
717
  function publishMode(extra = {}) {
588
718
  const evt = { mode: renderMode, busy: false, error: null, ...extra };
@@ -622,11 +752,14 @@ export function createViewer(container, part) {
622
752
  .map((n) => subMesh[n]);
623
753
  // `force` renders even while parked: a capture is offscreen work a parked
624
754
  // viewer still does, and it must not bake a stale (or absent) shadow.
755
+ // The cutaway's plane clips the shadow's casters too, so the half it cut
756
+ // away casts no shadow (cutaway.onChange below re-renders it as it moves).
757
+ const _shadowClipPlane = new THREE.Plane();
625
758
  function renderShadow(opts, { force = false } = {}) {
626
759
  if (!realisticRig || (!active && !force)) return;
627
760
  try {
628
- if (opts) realisticRig.shadow.render(scene, castersNow(), opts);
629
- else realisticRig.shadow.render(scene, castersNow());
761
+ const plane = cutaway.getPlane?.(_shadowClipPlane) ?? null;
762
+ realisticRig.shadow.render(scene, castersNow(), { ...opts, clippingPlanes: plane ? [plane] : null });
630
763
  } catch (e) {
631
764
  console.warn("partforge: contact shadow failed", e);
632
765
  }
@@ -659,6 +792,10 @@ export function createViewer(container, part) {
659
792
  renderShadow({ lowRes: true });
660
793
  }
661
794
  }
795
+ // A cutaway turned on or off, or its plane moved, changes what casts the
796
+ // contact shadow: low-res while the plane is dragged, full once it settles —
797
+ // the same schedule as a moving sub-part.
798
+ cutaway.onChange?.(() => { if (realisticRig) shadowMovedAt = performance.now(); });
662
799
 
663
800
  const livePixelRatio = (mode) =>
664
801
  Math.min(devicePixelRatio, mode === "realistic" && isCoarsePointer() ? 1.5 : 2);
@@ -677,12 +814,13 @@ export function createViewer(container, part) {
677
814
  // ground only moves on showAssembly, never because a capture happened.
678
815
  function enterRealistic(rig, { live = true, reground = true } = {}) {
679
816
  try {
817
+ syncPrintFrames(); // before the swap: the first realistic frame has the export-pose layers
680
818
  renderMode = "realistic";
681
819
  // A regen can land between the proxy compile and here, while the mode
682
820
  // was still CAD, so setSubGeometry skipped the UVs. Idempotent.
683
821
  for (const n of names) if (subCache[n] && physicalFor(n).userData.pfAnisotropic) ensureBoxUVs(subCache[n]);
684
822
  setSubMaterials(physicalFor);
685
- if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
823
+ if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group, ...(realisticRig.lights ?? []));
686
824
  realisticRig = rig;
687
825
  scene.environment = rig.envMap;
688
826
  scene.background = rig.background;
@@ -690,13 +828,15 @@ export function createViewer(container, part) {
690
828
  scene.backgroundIntensity = rig.backgroundIntensity ?? 1;
691
829
  scene.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
692
830
  scene.environmentRotation.set(0, rig.rotationY ?? 0, 0);
693
- scene.add(rig.ground, rig.shadow.group);
831
+ scene.add(rig.ground, rig.shadow.group, ...(rig.lights ?? []));
694
832
  for (const l of Object.values(liveLights)) l.visible = false;
695
833
  grid.visible = false;
696
834
  renderer.toneMapping = THREE.NeutralToneMapping;
697
835
  renderer.toneMappingExposure = rig.exposure;
698
836
  if (reground) placeGround({ force: !live });
699
837
  if (live) applyPixelRatio("realistic");
838
+ // Hides the lines on every sub-part now that renderMode reads realistic.
839
+ refreshFeatureLines();
700
840
  } catch (e) {
701
841
  try { enterCad({ live }); } catch (rollback) { console.warn("partforge: rolling back to CAD failed", rollback); }
702
842
  throw e;
@@ -714,7 +854,7 @@ export function createViewer(container, part) {
714
854
  });
715
855
  renderMode = "cad";
716
856
  attempt(() => {
717
- if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
857
+ if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group, ...(realisticRig.lights ?? []));
718
858
  });
719
859
  realisticRig = null;
720
860
  shadowMovedAt = null;
@@ -730,6 +870,7 @@ export function createViewer(container, part) {
730
870
  attempt(() => { grid.visible = true; });
731
871
  for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
732
872
  if (live) attempt(() => applyPixelRatio("cad"));
873
+ attempt(refreshFeatureLines);
733
874
  if (firstError) throw firstError;
734
875
  }
735
876
 
@@ -743,6 +884,7 @@ export function createViewer(container, part) {
743
884
  // target bound — each only for the synchronous compile() inside
744
885
  // compileAsync, so the view on screen is untouched while it waits.
745
886
  function compileRealistic(rig, { forCapture = false } = {}) {
887
+ syncPrintFrames();
746
888
  const proxy = new THREE.Scene();
747
889
  proxy.environment = rig.envMap;
748
890
  for (const n of names) {
@@ -795,6 +937,10 @@ export function createViewer(container, part) {
795
937
  if (isStale(token)) return renderMode;
796
938
  await compileRealistic(rig);
797
939
  if (isStale(token)) return renderMode;
940
+ // The ground's and materials' maps: without this the floor pops in a
941
+ // frame or two after the switch, untextured first.
942
+ await whenTexturesSettled();
943
+ if (isStale(token)) return renderMode;
798
944
  enterRealistic(rig);
799
945
  realisticPending = false;
800
946
  publishMode();
@@ -839,6 +985,32 @@ export function createViewer(container, part) {
839
985
  for (const [n, m] of physicalMats) m.userData.patternUniforms?.pfPrintFrame.value.fromArray(printFrames[n] ?? identity);
840
986
  }
841
987
 
988
+ // The lazy form mount uses. Computing a frame runs the sub-part's pose probe
989
+ // (two geometry-free builds for a placed sub-part), and every sub-part with
990
+ // no material draws layer lines, so frames are PULLED only when something is
991
+ // about to draw the realistic look: the live view (or a switch loading),
992
+ // and any capture that borrows it — compileRealistic and enterRealistic are
993
+ // the two doors every such path goes through. A delivery just marks them
994
+ // stale (invalidatePrintFrames); in CAD nothing is computed.
995
+ function setPrintFrameSource(source) {
996
+ printFrameSource = typeof source === "function" ? source : null;
997
+ invalidatePrintFrames();
998
+ }
999
+ function invalidatePrintFrames() {
1000
+ printFramesStale = true;
1001
+ if (renderMode === "realistic" || realisticPending) syncPrintFrames();
1002
+ }
1003
+ function syncPrintFrames() {
1004
+ if (!printFrameSource || !printFramesStale) return;
1005
+ printFramesStale = false;
1006
+ let frames;
1007
+ try { frames = printFrameSource(); } catch (e) {
1008
+ console.warn("partforge: computing print frames failed", e);
1009
+ return;
1010
+ }
1011
+ setPrintFrames(frames);
1012
+ }
1013
+
842
1014
  // Resolves once the current environment's rig is loaded (captures wait on it).
843
1015
  const whenRealisticReady = () => rigFor(environmentId).then(() => {});
844
1016
 
@@ -849,10 +1021,12 @@ export function createViewer(container, part) {
849
1021
  for (const p of rigCache.values()) p.then((r) => r.dispose(), () => {});
850
1022
  rigCache.clear();
851
1023
  loadedRigs.clear();
1024
+ borrowedRigRefs.clear();
852
1025
  for (const m of physicalMats.values()) m.dispose();
853
1026
  physicalMats.clear();
854
1027
  for (const t of textureCache.values()) t.dispose();
855
1028
  textureCache.clear();
1029
+ pendingTextures.clear();
856
1030
  pmrem?.dispose();
857
1031
  pmrem = null;
858
1032
  }
@@ -864,6 +1038,10 @@ export function createViewer(container, part) {
864
1038
  const frameListeners = new Set();
865
1039
  function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
866
1040
 
1041
+ // Fired at the end of every showAssembly, for hosts that need to react to
1042
+ // which sub-parts are visible (e.g. re-deriving a view style thumbnail).
1043
+ const assemblyListeners = new Set();
1044
+
867
1045
  const camTween = createCameraTween();
868
1046
 
869
1047
  // OrbitControls' damping is a rotational RESIDUAL, not a per-frame effect: it
@@ -912,7 +1090,23 @@ export function createViewer(container, part) {
912
1090
  // the tween would re-zoom on every frame of a 0.6s cue (see setCameraState's
913
1091
  // comment, which is where that reasoning is written down). Composed with the
914
1092
  // caller's own onComplete rather than replacing it.
915
- function tweenCameraTo(viewName, { duration = 0.6, onComplete, refit = false } = {}) {
1093
+ //
1094
+ // `autoProjection` is the view cube's alone (its canvas clicks and its
1095
+ // per-face keyboard buttons): a tween that lands on a FACE view — straight
1096
+ // down a world axis — settles into orthographic at the end. Deliberately an
1097
+ // explicit option rather than inferred from the view name, because an
1098
+ // animation camera cue to "front" means "look from the front", not "switch
1099
+ // projection"; cues never pass it, so they never ENTER ortho.
1100
+ //
1101
+ // Leaving ortho is the same for every tween: one whose destination is not a
1102
+ // face view (an edge, a corner, iso) swaps back to perspective at the START.
1103
+ // At the start because the part is still seen head-on there, where the two
1104
+ // projections differ least, and the rotation that follows then reads as the
1105
+ // ordinary perspective orbit it is; swapping at the end instead would spin
1106
+ // the part flat and then pop the depth in at an oblique angle, where the
1107
+ // difference is largest. A tween between two faces while ortho stays ortho
1108
+ // the whole way — no projection change at all, rather than two in 0.6s.
1109
+ function tweenCameraTo(viewName, { duration = 0.6, onComplete, refit = false, autoProjection = false } = {}) {
916
1110
  const box = getVisibleWorldBounds();
917
1111
  if (!box || box.isEmpty()) { onComplete?.(); return; }
918
1112
  const center = box.getCenter(new THREE.Vector3()).toArray();
@@ -920,11 +1114,24 @@ export function createViewer(container, part) {
920
1114
  // radius = full max extent (not half), matching frameTo's framing distance so a
921
1115
  // live camera cue doesn't land twice as close as the reframe button and crop the part.
922
1116
  const pose = cameraPoseForView(viewName, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
1117
+ const toDir = new THREE.Vector3().fromArray(pose.position).sub(new THREE.Vector3().fromArray(pose.target));
1118
+ const toFace = isFaceAligned(toDir.toArray());
1119
+ if (!toFace && projectionMode === "orthographic") setProjection("perspective");
923
1120
  // The projection is read at COMPLETION, not now: a 0.6s tween is long
924
- // enough for the user to have toggled projection under it.
1121
+ // enough for a host to have changed it under it.
925
1122
  const finish = () => {
926
1123
  restoreDamping();
927
- if (refit && projectionMode === "orthographic") {
1124
+ if (autoProjection && toFace && projectionMode !== "orthographic") {
1125
+ // Settle into the face view. The tween fires onComplete from inside its
1126
+ // own update(), BEFORE the render loop writes the final pose onto the
1127
+ // camera, so put it there first — the render loop then writes the same
1128
+ // values onto the (now orthographic) camera. setProjection sizes the
1129
+ // frustum from the camera's distance, which is the pose's framing
1130
+ // distance, so this is also the refit, with zoom 1.
1131
+ activeCamera.position.fromArray(pose.position);
1132
+ controls.target.fromArray(pose.target);
1133
+ setProjection("orthographic");
1134
+ } else if (refit && projectionMode === "orthographic") {
928
1135
  // The tween fires onComplete from inside its own update(), BEFORE the
929
1136
  // render loop writes the final pose onto the camera — so the camera is
930
1137
  // still a frame short of where it is going. Frame from the distance
@@ -932,8 +1139,14 @@ export function createViewer(container, part) {
932
1139
  syncOrthoToPerspectiveFraming({
933
1140
  distance: new THREE.Vector3().fromArray(pose.position)
934
1141
  .distanceTo(new THREE.Vector3().fromArray(pose.target)),
1142
+ target: new THREE.Vector3().fromArray(pose.target),
1143
+ direction: toDir,
935
1144
  });
936
1145
  }
1146
+ // Still ortho at the end (a face-to-face tween, a cue onto a face, or the
1147
+ // switch just above): the face this tween landed on is the axis a
1148
+ // rotation now has to leave.
1149
+ if (projectionMode === "orthographic") orthoAxis = toDir.clone();
937
1150
  onComplete?.();
938
1151
  };
939
1152
  suspendDamping();
@@ -997,20 +1210,189 @@ export function createViewer(container, part) {
997
1210
  orthoCamera.updateProjectionMatrix();
998
1211
  }
999
1212
 
1000
- // Re-derive the ortho frustum from the perspective camera's fov at the
1001
- // camera's CURRENT distance from the orbit target. Called on every swap into
1002
- // ortho and after any reframe, which is what keeps the two projections
1003
- // showing the same amount of part.
1213
+ // Where a projection swap matches size. The two projections agree on the
1214
+ // size of things at exactly ONE depth — an orthographic view draws every
1215
+ // depth at one scale, a perspective one magnifies whatever is nearer — so the
1216
+ // swap has to choose it, and it chooses the surface the user is looking AT:
1217
+ // the first visible surface under the screen centre, found by a ray down the
1218
+ // view axis through the orbit target. It used to be the target's own depth
1219
+ // (the part's CENTRE, for a view cube face), while a face view shows the
1220
+ // part's FRONT: the swap back to perspective then magnified the surface on
1221
+ // screen by distance / (distance - depth), and since that distance is the
1222
+ // ortho zoom recovered as a dolly, zooming in made the jump grow (a planter
1223
+ // zoomed 2.27x jumped 22% wider and 53% taller).
1224
+ //
1225
+ // Returned as the signed distance of that surface from the target, along
1226
+ // `towardCamera` (target -> camera). Positive is in front of the target.
1227
+ // - What is visible is what is DRAWN: an opaque sub-part's front face
1228
+ // that survives the cutaway, or — with the cutaway on — the section cap
1229
+ // where the ray crosses the plane inside solid material (the cap is not a
1230
+ // sub-part mesh, so the ray finds it by parity rather than by a hit).
1231
+ // Ghosted sub-parts (a static display opacity below 1) are seen through.
1232
+ // - `maxDepth` is where a PERSPECTIVE camera sits: it cannot see anything
1233
+ // behind itself (an orthographic one can — its near plane may be
1234
+ // negative, see depth-range.js — so the exit swap searches the whole
1235
+ // line). A surface within MATCH_CLEARANCE of the camera is no surface
1236
+ // to match at: the target's depth is kept instead.
1237
+ // - Nothing on the ray (a hole, a gap between bodies, an edge-on sheet)
1238
+ // falls back to the front of the visible bounds: in a face view, the
1239
+ // nearest face of the part.
1240
+ // - Nothing at all shown: 0, the target's depth, as before.
1241
+ const _matchRaycaster = new THREE.Raycaster();
1242
+ const _matchDir = new THREE.Vector3();
1243
+ const _matchOrigin = new THREE.Vector3();
1244
+ const _matchCorner = new THREE.Vector3();
1245
+ const _matchSphere = new THREE.Sphere();
1246
+ const _matchNormal = new THREE.Vector3();
1247
+ const _matchNormalMat = new THREE.Matrix3();
1248
+ const _matchPlane = new THREE.Plane();
1249
+ const _equivDir = new THREE.Vector3();
1250
+ // Both sides of every triangle: the parity walk has to see the faces the ray
1251
+ // LEAVES a solid through, which a FrontSide material hides from a raycast.
1252
+ const _matchProxy = new THREE.Mesh(undefined, new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }));
1253
+ _matchProxy.matrixAutoUpdate = false;
1254
+ const MATCH_CLEARANCE = 1e-3; // of the visible bounds' diagonal
1255
+ // The ray's candidate surfaces are remembered per RAY and SCENE: the target,
1256
+ // the direction, the cutaway's plane (null while off — so a toggle, drag,
1257
+ // flip or reset asks afresh), and a generation bumped by every geometry,
1258
+ // pose or visibility change (setSubGeometry, setSubPose, showAssembly,
1259
+ // hideAssembly) — a regenerated pocket or a hidden inner sub-part can move
1260
+ // the surface without moving the bounds. Swapping in and back out asks
1261
+ // about the same ray, but not bit-for-bit: controls.update() re-derives the
1262
+ // camera position from spherical coordinates on every swap, so the
1263
+ // direction comes back an ulp off — and a ray through a triangle edge (a
1264
+ // target at the centre of a symmetric part is exactly that) can hit on one
1265
+ // side of the ulp and miss on the other, which turned an untouched round
1266
+ // trip into a 25% reframe. What is remembered is the whole candidate list,
1267
+ // not one answer, so a query from a different camera position (`maxDepth`)
1268
+ // still gets its own, correct answer from it.
1269
+ let matchMemo = null; // { generation, target, dir, plane, candidates, fallback, clearance }
1270
+ const RAY_EPS = 1e-9;
1271
+ const samePlane = (a, b) => (a === null || b === null ? a === b
1272
+ : a.normal.distanceTo(b.normal) <= RAY_EPS && Math.abs(a.constant - b.constant) <= RAY_EPS * (1 + Math.abs(a.constant)));
1273
+ function sizeMatchDepth(target, towardCamera, { maxDepth = Infinity } = {}) {
1274
+ if (towardCamera.lengthSq() === 0) return 0;
1275
+ const box = getVisibleWorldBounds();
1276
+ if (box.isEmpty()) return 0;
1277
+ _matchDir.copy(towardCamera).normalize();
1278
+ const plane = cutaway.getPlane?.(_matchPlane) ?? null;
1279
+ const tol = RAY_EPS * (1 + box.max.distanceTo(box.min));
1280
+ let m = matchMemo;
1281
+ if (!(m && m.generation === matchGeneration && m.target.distanceTo(target) <= tol
1282
+ && m.dir.dot(_matchDir) >= 1 - RAY_EPS && samePlane(m.plane, plane))) {
1283
+ m = matchMemo = {
1284
+ generation: matchGeneration,
1285
+ target: target.clone(),
1286
+ dir: _matchDir.clone(),
1287
+ plane: plane && plane.clone(),
1288
+ ...matchCandidates(target, box, plane),
1289
+ };
1290
+ }
1291
+ // Candidates are nearest-the-camera first: the first one in front of the
1292
+ // camera, clear of it, is what the camera sees.
1293
+ for (const depth of m.candidates) {
1294
+ if (depth > maxDepth) continue; // behind a perspective camera
1295
+ return maxDepth - depth < m.clearance ? 0 : depth;
1296
+ }
1297
+ if (maxDepth - m.fallback < m.clearance) return 0;
1298
+ return m.fallback;
1299
+ }
1300
+ function matchCandidates(target, box, plane) {
1301
+ // Started outside everything shown rather than at the camera, so the ray
1302
+ // does not depend on where a perspective camera happens to be (an ortho
1303
+ // camera's position is a direction more than a place).
1304
+ box.getBoundingSphere(_matchSphere);
1305
+ const outside = target.distanceTo(_matchSphere.center) + _matchSphere.radius + 1;
1306
+ _matchOrigin.copy(target).addScaledVector(_matchDir, outside);
1307
+ const rayDir = _matchDir.clone().negate(); // camera -> target
1308
+ _matchRaycaster.set(_matchOrigin, rayDir);
1309
+ _matchRaycaster.far = 2 * outside;
1310
+ const hits = [];
1311
+ for (const n of names) {
1312
+ const mesh = subMesh[n];
1313
+ if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
1314
+ if ((part.parts[n].display?.opacity ?? 1) < 1) continue; // a ghost is seen through
1315
+ mesh.updateWorldMatrix(true, false);
1316
+ _matchProxy.geometry = mesh.geometry;
1317
+ _matchProxy.matrixWorld.copy(mesh.matrixWorld);
1318
+ _matchNormalMat.getNormalMatrix(mesh.matrixWorld);
1319
+ for (const hit of _matchRaycaster.intersectObject(_matchProxy, false)) {
1320
+ _matchNormal.copy(hit.face.normal).applyMatrix3(_matchNormalMat);
1321
+ hits.push({ t: hit.distance, point: hit.point, entering: _matchNormal.dot(rayDir) < 0 });
1322
+ }
1323
+ }
1324
+ _matchProxy.geometry = undefined;
1325
+ hits.sort((x, y) => x.t - y.t);
1326
+ // Where the ray crosses the cut plane, heading INTO the half that is kept:
1327
+ // a cap is drawn there if the crossing is inside solid material.
1328
+ let capT = null;
1329
+ if (plane) {
1330
+ const denom = plane.normal.dot(rayDir);
1331
+ if (denom > 0) {
1332
+ const t = -(plane.normal.dot(_matchOrigin) + plane.constant) / denom;
1333
+ if (t > 0) capT = t;
1334
+ }
1335
+ }
1336
+ const candidates = [];
1337
+ let inside = 0;
1338
+ let capDone = capT === null;
1339
+ for (const hit of hits) {
1340
+ if (!capDone && hit.t >= capT) {
1341
+ capDone = true;
1342
+ if (inside > 0) candidates.push(outside - capT);
1343
+ }
1344
+ if (hit.entering && (cutaway.isPointVisible?.(hit.point) ?? true)) candidates.push(outside - hit.t);
1345
+ inside += hit.entering ? 1 : -1;
1346
+ }
1347
+ let fallback = -Infinity;
1348
+ for (let i = 0; i < 8; i++) {
1349
+ _matchCorner.set(i & 1 ? box.max.x : box.min.x, i & 2 ? box.max.y : box.min.y, i & 4 ? box.max.z : box.min.z);
1350
+ fallback = Math.max(fallback, _matchCorner.sub(target).dot(_matchDir));
1351
+ }
1352
+ return {
1353
+ candidates,
1354
+ fallback: Number.isFinite(fallback) ? fallback : 0,
1355
+ clearance: MATCH_CLEARANCE * box.max.distanceTo(box.min),
1356
+ };
1357
+ }
1358
+
1359
+ // The perspective distance (target -> camera) that shows what the ortho
1360
+ // camera shows now, at the size-match surface above. OrbitControls zooms an
1361
+ // ortho camera through camera.zoom rather than by moving it, which is why
1362
+ // this reads the zoom. Falls back to the target's depth if the surface is so
1363
+ // far BEHIND the target that the camera would have to sit behind the target
1364
+ // to match it (a target panned out in front of everything).
1365
+ function orthoEquivalentDistance() {
1366
+ const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
1367
+ // `|| 1` on the zoom for the same reason captureCurrent guards it: a zero
1368
+ // would make this non-finite.
1369
+ const atSurface = perspectiveDistance({ halfH, zoom: orthoCamera.zoom || 1, fovDeg: camera.fov });
1370
+ const depth = sizeMatchDepth(controls.target, _equivDir.copy(orthoCamera.position).sub(controls.target));
1371
+ const distance = atSurface + depth;
1372
+ return distance > atSurface * 0.05 ? distance : atSurface;
1373
+ }
1374
+
1375
+ // Re-derive the ortho frustum from the perspective camera's fov so that it
1376
+ // shows what a perspective camera at the CURRENT distance from the orbit
1377
+ // target shows at the size-match surface (sizeMatchDepth). Called on every
1378
+ // swap into ortho and after any reframe, which is what keeps the two
1379
+ // projections showing the same amount of part.
1004
1380
  //
1005
- // `distance` overrides "current": tweenCameraTo's refit runs from the tween's
1006
- // completion callback, which fires one frame BEFORE the final pose reaches the
1007
- // camera, so it frames from the distance it asked for rather than from where
1008
- // the camera happens to be at that instant.
1009
- function syncOrthoToPerspectiveFraming({ distance: atDistance } = {}) {
1010
- const distance = atDistance || activeCamera.position.distanceTo(controls.target) || 1;
1381
+ // `distance` / `target` / `direction` override "current": tweenCameraTo's
1382
+ // refit runs from the tween's completion callback, which fires one frame
1383
+ // BEFORE the final pose reaches the camera, so it frames from the pose it
1384
+ // asked for rather than from where the camera happens to be at that instant.
1385
+ const _syncTarget = new THREE.Vector3();
1386
+ const _syncDir = new THREE.Vector3();
1387
+ function syncOrthoToPerspectiveFraming({ distance: atDistance, target, direction } = {}) {
1388
+ _syncTarget.copy(target ?? controls.target);
1389
+ if (direction) _syncDir.copy(direction);
1390
+ else _syncDir.copy(activeCamera.position).sub(_syncTarget);
1391
+ const distance = atDistance || _syncDir.length() || 1;
1392
+ const depth = sizeMatchDepth(_syncTarget, _syncDir, { maxDepth: distance });
1011
1393
  applyOrthoFrustum(orthoFrustum({
1012
1394
  fovDeg: camera.fov,
1013
- distance,
1395
+ distance: distance - depth,
1014
1396
  aspect: camera.aspect || 1,
1015
1397
  }));
1016
1398
  // The frustum now expresses the whole framing, so any dolly-by-zoom the user
@@ -1035,7 +1417,8 @@ export function createViewer(container, part) {
1035
1417
  } else {
1036
1418
  // Recover whatever dolly the user did while in ortho: OrbitControls
1037
1419
  // changes camera.zoom there rather than moving the camera, so the zoom
1038
- // has to come back as a distance or the part jumps size.
1420
+ // has to come back as a distance or the part jumps size — matched at the
1421
+ // surface the user is looking at (orthoEquivalentDistance).
1039
1422
  //
1040
1423
  // The bound exists because ortho zoom is UNBOUNDED and zooming a long way
1041
1424
  // out costs nothing there (an ortho projection has no depth falloff) —
@@ -1046,17 +1429,15 @@ export function createViewer(container, part) {
1046
1429
  // which is the camera that is still live and therefore the one
1047
1430
  // updateDepthRange has been keeping current. `far * 0.9` alone would be
1048
1431
  // too eager: frameTo frames at 2.6r + 6 MILLIMETRES, so an everyday 300mm
1049
- // part sits at 786mm and a plain toggle would silently reframe it closer.
1432
+ // part sits at 786mm and a plain swap would silently reframe it closer.
1050
1433
  // Hence the max with the distance the camera is already at, which makes an
1051
- // untouched round trip (zoom === 1, where orthoFrustum/perspectiveDistance
1052
- // are exact inverses) lossless for a part of ANY size, and still never lets
1053
- // a degenerate zoom move the camera further out than it already was.
1054
- // `|| 1` on the zoom for the same reason captureCurrent guards it: a zero
1055
- // would make this non-finite.
1056
- const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
1434
+ // untouched round trip (zoom === 1, where the entry and this exit match at
1435
+ // the same surface and are exact inverses) lossless for a part of ANY
1436
+ // size, and still never lets a degenerate zoom move the camera further
1437
+ // out than it already was.
1057
1438
  const offset = from.position.clone().sub(controls.target);
1058
1439
  const distance = Math.min(
1059
- perspectiveDistance({ halfH, zoom: orthoCamera.zoom || 1, fovDeg: camera.fov }),
1440
+ orthoEquivalentDistance(),
1060
1441
  Math.max(from.far * 0.9, offset.length()),
1061
1442
  );
1062
1443
  camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
@@ -1080,12 +1461,16 @@ export function createViewer(container, part) {
1080
1461
  // controls.update() ends in Object3D.lookAt, which does refresh matrixWorld
1081
1462
  // — but it refreshes BEFORE writing the new quaternion, so a rotation
1082
1463
  // applied inside that same update (damping momentum still decaying as the
1083
- // toggle lands) leaves the rotation one frame behind. One matrix compose is
1464
+ // swap lands) leaves the rotation one frame behind. One matrix compose is
1084
1465
  // cheaper than depending on that ordering. Placed after controls.update()
1085
1466
  // for the same reason: it is the last writer of the pose.
1086
1467
  to.updateMatrixWorld();
1087
1468
  cutaway.setCamera(to);
1088
1469
  projectionMode = next;
1470
+ // Ortho is armed on the direction it was entered on, whoever entered it (a
1471
+ // face click, a host's runtime.projection.set, a restore): the first
1472
+ // rotation off it returns to perspective the same way for all of them.
1473
+ orthoAxis = next === "orthographic" ? to.position.clone().sub(controls.target) : null;
1089
1474
  for (const cb of [...projectionListeners]) cb(projectionMode);
1090
1475
  return projectionMode;
1091
1476
  }
@@ -1095,6 +1480,30 @@ export function createViewer(container, part) {
1095
1480
  return () => projectionListeners.delete(cb);
1096
1481
  }
1097
1482
 
1483
+ // Run every frame, after controls.update() and the tween have written the
1484
+ // pose: the first frame the view direction has turned off the ortho axis is a
1485
+ // ROTATION (a canvas drag, the cube's drag, a user grab that cancelled a tween
1486
+ // mid-turn), and it swaps back to perspective, size-preserving, from the
1487
+ // pose it has only just left. Direction rather than OrbitControls' "start"
1488
+ // event, because "start" also fires for pan and zoom, which keep the face
1489
+ // view. Here in the render loop rather than in a controls "change" listener
1490
+ // so setProjection's own controls.update() never runs nested inside one.
1491
+ // Skipped while a tween is in flight: it owns the camera, and decides the
1492
+ // projection itself (tweenCameraTo).
1493
+ // Allocation-free (it runs every frame while ortho): one scratch vector and
1494
+ // a dot product against the cosine of projection.js's epsilon — the same
1495
+ // test as hasLeftAxis, inlined.
1496
+ const _autoDir = new THREE.Vector3();
1497
+ const _axisUnit = new THREE.Vector3();
1498
+ function checkAutoProjection() {
1499
+ if (projectionMode !== "orthographic" || !orthoAxis || camTween.isActive()) return;
1500
+ _autoDir.copy(activeCamera.position).sub(controls.target);
1501
+ const len = _autoDir.length(), axisLen = orthoAxis.length();
1502
+ if (len === 0 || axisLen === 0) return;
1503
+ _axisUnit.copy(orthoAxis).divideScalar(axisLen);
1504
+ if (_autoDir.dot(_axisUnit) / len < FACE_ALIGN_COS) setProjection("perspective");
1505
+ }
1506
+
1098
1507
  // Fallback creasing for payloads with no kernel normals. Both backends now
1099
1508
  // ship authoritative normals (Manifold: policy-aware crease pass; OCCT:
1100
1509
  // analytic B-rep normals), so this path is last-ditch only — it must not be
@@ -1137,12 +1546,14 @@ export function createViewer(container, part) {
1137
1546
  const subCache = Object.fromEntries(names.map((n) => [n, null]));
1138
1547
 
1139
1548
  function setSubGeometry(name, payload) {
1549
+ matchGeneration++;
1140
1550
  setSubPose(name, null); // fresh worker mesh is baked at current params — clear any fast-path pose
1141
1551
  const prev = subCache[name];
1142
1552
  const next = buildGeometry(payload);
1143
1553
  // Brushed metal needs UVs for its tangent frame; CAD meshes carry none.
1144
1554
  if (renderMode === "realistic" && physicalMats.get(name)?.userData.pfAnisotropic) ensureBoxUVs(next);
1145
1555
  subCache[name] = next;
1556
+ syncGrain(name);
1146
1557
  // Section helpers must stop referring to the old buffers before those
1147
1558
  // buffers are released.
1148
1559
  cutaway.updateGeometry(name, next);
@@ -1154,6 +1565,7 @@ export function createViewer(container, part) {
1154
1565
  // three.js Matrix4 convention). Never affects exports or geometry — the worker
1155
1566
  // owns real placement; this only re-poses the delivered mesh.
1156
1567
  function setSubPose(name, mat16) {
1568
+ matchGeneration++;
1157
1569
  for (const obj of [subMesh[name], subLines[name]]) {
1158
1570
  if (!obj) continue;
1159
1571
  obj.matrixAutoUpdate = false;
@@ -1173,6 +1585,7 @@ export function createViewer(container, part) {
1173
1585
  // --- show / hide assembly -------------------------------------------------
1174
1586
  const _box = new THREE.Box3();
1175
1587
  const _posedBox = new THREE.Box3();
1588
+ const _frameDir = new THREE.Vector3();
1176
1589
 
1177
1590
  // Recentre the assembly on the pivot and frame the camera to the named parts.
1178
1591
  // Cached bounding boxes are in the delivered mesh's own frame, so any fast-path
@@ -1193,7 +1606,16 @@ export function createViewer(container, part) {
1193
1606
  floorY = -size.z / 2;
1194
1607
  grid.position.y = floorY;
1195
1608
  const r = Math.max(size.x, size.y, size.z) || 12;
1196
- activeCamera.position.setLength(r * 2.6 + 6);
1609
+ // Keep the VIEW DIRECTION (target -> camera), not the camera's direction
1610
+ // from the origin: after a pan the target is off the origin, and scaling
1611
+ // the position about the origin would turn the view — which, in an ortho
1612
+ // face view, reads as a rotation and drops to perspective at an off-axis
1613
+ // angle on the next frame. Falls back to the old origin-relative direction
1614
+ // for a degenerate offset (camera on its target).
1615
+ _frameDir.copy(activeCamera.position).sub(controls.target);
1616
+ if (_frameDir.lengthSq() === 0) _frameDir.copy(activeCamera.position);
1617
+ if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
1618
+ activeCamera.position.copy(_frameDir.normalize().multiplyScalar(r * 2.6 + 6));
1197
1619
  controls.target.set(0, 0, 0);
1198
1620
  // Framing under ortho is a frustum, not a distance — without this the
1199
1621
  // reframe button moves the camera and nothing visibly changes.
@@ -1204,6 +1626,7 @@ export function createViewer(container, part) {
1204
1626
  // frame the camera to them — done only on the initial show and on view (tab)
1205
1627
  // changes, NOT on regeneration, so a user's zoom/orbit survives editing params.
1206
1628
  function showAssembly(visibleNames, { frame = false } = {}) {
1629
+ matchGeneration++;
1207
1630
  lastShown = [...visibleNames];
1208
1631
  for (const name of names) {
1209
1632
  if (visibleNames.includes(name)) {
@@ -1218,6 +1641,9 @@ export function createViewer(container, part) {
1218
1641
  if (frame) frameTo(visibleNames);
1219
1642
  cutaway.setVisible(effectiveVisible());
1220
1643
  placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
1644
+ for (const cb of [...assemblyListeners]) {
1645
+ try { cb(); } catch (e) { console.warn("partforge: assembly listener failed", e); }
1646
+ }
1221
1647
  }
1222
1648
 
1223
1649
  // Re-frame whatever is currently visible (the reframe button).
@@ -1273,6 +1699,7 @@ export function createViewer(container, part) {
1273
1699
  }
1274
1700
 
1275
1701
  function hideAssembly() {
1702
+ matchGeneration++;
1276
1703
  lastShown = [];
1277
1704
  for (const m of Object.values(subMesh)) m.visible = false;
1278
1705
  for (const l of Object.values(subLines)) l.visible = false;
@@ -1435,7 +1862,7 @@ export function createViewer(container, part) {
1435
1862
  for (const dot of [...flashDots, ...captureHidden]) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
1436
1863
  try {
1437
1864
  renderer.setRenderTarget(rt);
1438
- renderer.render(renderScene, cam);
1865
+ renderWithBackdrop(renderScene, cam);
1439
1866
  // render() resolves the multisample renderbuffer into the target texture, so this
1440
1867
  // reads antialiased pixels.
1441
1868
  renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
@@ -1470,6 +1897,14 @@ export function createViewer(container, part) {
1470
1897
  // Pointer feedback excluded from EVERY offscreen render (renderOffscreen),
1471
1898
  // showcase captures included — unlike canonicalCaptureHidden below.
1472
1899
  const captureHidden = new Set();
1900
+ // The cutaway's ghost plane is its gizmo, not its section: the part as the
1901
+ // user sees it (clipped, with its hatched caps) goes into a capture, the
1902
+ // translucent plane in front of it does not. It sits between the camera and
1903
+ // everything the cut keeps, and a capture blends it in linear light, where
1904
+ // it lands several times stronger than on the canvas — the view style
1905
+ // popover's thumbnails came back with the part washed out behind a blue
1906
+ // sheet, a dark part all but gone.
1907
+ for (const obj of cutaway.captureExcluded ?? []) captureHidden.add(obj);
1473
1908
  function registerCaptureHidden(obj) {
1474
1909
  captureHidden.add(obj);
1475
1910
  return () => captureHidden.delete(obj);
@@ -1491,7 +1926,8 @@ export function createViewer(container, part) {
1491
1926
  // renders, and the editor's on-screen mode must never change what the agent
1492
1927
  // sees. A realistic live view lends its scene to CAD for the synchronous
1493
1928
  // capture and gets it back exactly (captureIn). renderViews is the one way
1494
- // to ask for realistic canonical views.
1929
+ // to ask for realistic canonical views. Feature lines are CAD-only, so
1930
+ // borrowing CAD for this capture draws them for free.
1495
1931
  function captureCanonicalViews(viewNames) {
1496
1932
  if (disposed) return [];
1497
1933
  const box = getVisibleWorldBounds();
@@ -1598,20 +2034,75 @@ export function createViewer(container, part) {
1598
2034
  }
1599
2035
  }
1600
2036
 
2037
+ // One small render of the CURRENT framing in any style, for the view style
2038
+ // popover. The live view is put back exactly (same contract as captureIn:
2039
+ // no publish, nothing persisted), whether it is CAD, or realistic in this
2040
+ // or another environment. The borrowed style brings its own feature-lines
2041
+ // visibility with it for free, since it's decided by render mode alone.
2042
+ async function renderStyleThumbnail(style, { size = 256 } = {}) {
2043
+ if (disposed) return null;
2044
+ const box = getVisibleWorldBounds();
2045
+ if (!box || box.isEmpty()) return null;
2046
+ const capture = () => currentFramingInCurrentLook({ size, quality: 0.8 });
2047
+ if (style === "cad") return captureIn("cad", null, capture);
2048
+ const id = resolveEnvironmentId(style).id;
2049
+ // Borrowed BEFORE awaiting (see releaseRig).
2050
+ const p = borrowRig(id);
2051
+ try {
2052
+ const rig = await p;
2053
+ if (disposed) return null;
2054
+ await compileRealistic(rig, { forCapture: true });
2055
+ if (disposed) return null;
2056
+ await whenTexturesSettled();
2057
+ if (disposed) return null;
2058
+ if (renderMode !== "realistic" || realisticRig === rig) return captureIn("realistic", rig, capture);
2059
+ // Realistic in another environment: borrow this rig, then put the live one back.
2060
+ const liveRig = realisticRig, movedAt = shadowMovedAt;
2061
+ try {
2062
+ enterRealistic(rig, { live: false });
2063
+ return capture();
2064
+ } finally {
2065
+ try {
2066
+ enterRealistic(liveRig, { live: false, reground: false });
2067
+ shadowMovedAt = movedAt;
2068
+ } catch (e) {
2069
+ // enterRealistic already rolled the scene back to CAD: make it the live mode.
2070
+ console.warn("partforge: restoring the realistic view after a thumbnail failed", e);
2071
+ try { applyPixelRatio("cad"); } catch { /* the view is CAD either way */ }
2072
+ publishMode({ error: "couldn't load realistic view" });
2073
+ }
2074
+ }
2075
+ } finally {
2076
+ releaseRig(id, p);
2077
+ }
2078
+ }
2079
+
1601
2080
  // Agent-facing canonical renders in a chosen appearance, whatever the live
1602
2081
  // view shows. CAD is exactly captureCanonicalViews (which is always CAD).
1603
2082
  // Realistic waits for the current environment's rig and the capture's
1604
2083
  // shader programs, then borrows the realistic look for the synchronous
1605
2084
  // capture only if the live view is not already realistic. A rig that fails
1606
- // to load rejects rather than silently returning CAD images.
2085
+ // to load rejects rather than silently returning CAD images. Feature lines
2086
+ // are CAD-only, so borrowing the realistic look draws without them for free.
1607
2087
  async function renderViews(viewNames, { renderMode: want = "cad" } = {}) {
1608
2088
  if (disposed) return [];
1609
2089
  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));
2090
+ // Borrowed like a thumbnail's rig, so a thumbnail of the same environment
2091
+ // finishing first cannot dispose it mid-capture (and, in CAD, it is freed
2092
+ // once this is done — it is not on screen).
2093
+ const id = environmentId;
2094
+ const p = borrowRig(id);
2095
+ try {
2096
+ const rig = await p;
2097
+ if (disposed) return [];
2098
+ await compileRealistic(rig, { forCapture: true });
2099
+ if (disposed) return [];
2100
+ await whenTexturesSettled();
2101
+ if (disposed) return [];
2102
+ return captureIn("realistic", rig, () => canonicalViewsInCurrentLook(viewNames));
2103
+ } finally {
2104
+ releaseRig(id, p);
2105
+ }
1615
2106
  }
1616
2107
 
1617
2108
  // Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
@@ -1676,7 +2167,7 @@ export function createViewer(container, part) {
1676
2167
  try {
1677
2168
  // fov comes from the PERSPECTIVE camera, deliberately, not from whichever
1678
2169
  // camera is live: thumbnails are canonical captures and stay perspective
1679
- // however the user has the projection toggled. cameraPoseForView's distance
2170
+ // whichever projection is live. cameraPoseForView's distance
1680
2171
  // is tuned to this fov, so a narrower one would crop long, thin parts.
1681
2172
  return renderOffscreen(
1682
2173
  pose,
@@ -1698,6 +2189,64 @@ export function createViewer(container, part) {
1698
2189
  }
1699
2190
  }
1700
2191
 
2192
+ // --- the environment backdrop under an orthographic camera ---------------
2193
+ // three draws a texture `scene.background` (the realistic environment photo)
2194
+ // as a unit box around the camera, projected through the camera's own
2195
+ // projection (WebGLBackground + the backgroundCube shader). Under a
2196
+ // PerspectiveCamera that box fills the frame; under an OrthographicCamera it
2197
+ // projects to a one-unit square in a frustum tens of millimetres wide or
2198
+ // more — a speck behind the part — so an ortho face view (the view cube
2199
+ // settles into one) lost its whole backdrop to the clear colour, on the
2200
+ // canvas and in every capture of that framing. So under ortho the backdrop is
2201
+ // its own pass: three's own background draw (same PMREM, blur, intensity,
2202
+ // rotation) through a perspective camera with the ortho camera's orientation
2203
+ // and the live perspective camera's fov, then the scene through the ortho
2204
+ // camera on top, clearing depth and stencil but not colour. The backdrop is
2205
+ // exactly what perspective shows looking the same way. A colour background
2206
+ // (CAD) clears the same under either projection and keeps the one pass.
2207
+ // Residual: a transmissive material in the scene pass refracts the clear
2208
+ // colour rather than the environment (its transmission pass reads
2209
+ // scene.background, which is null for that pass) — under ortho only.
2210
+ const _backdropCam = new THREE.PerspectiveCamera(45, 1, 0.1, 10);
2211
+ const _backdropScene = new THREE.Scene();
2212
+ function renderWithBackdrop(renderScene, cam) {
2213
+ const background = renderScene.background;
2214
+ if (!cam.isOrthographicCamera || !background?.isTexture) {
2215
+ renderer.render(renderScene, cam);
2216
+ return;
2217
+ }
2218
+ cam.updateMatrixWorld();
2219
+ cam.matrixWorld.decompose(_backdropCam.position, _backdropCam.quaternion, _backdropCam.scale);
2220
+ _backdropCam.up.copy(cam.up);
2221
+ _backdropCam.fov = camera.fov;
2222
+ _backdropCam.aspect = (cam.right - cam.left) / (cam.top - cam.bottom) || 1;
2223
+ // A recentred capture renders a sub-window of a larger frame: the backdrop
2224
+ // is cropped the same way, so it lines up with the scene pass.
2225
+ if (cam.view?.enabled) {
2226
+ const { fullWidth, fullHeight, offsetX, offsetY, width, height } = cam.view;
2227
+ _backdropCam.setViewOffset(fullWidth, fullHeight, offsetX, offsetY, width, height);
2228
+ } else {
2229
+ _backdropCam.clearViewOffset();
2230
+ }
2231
+ _backdropCam.updateProjectionMatrix();
2232
+ _backdropCam.updateMatrixWorld(true);
2233
+ _backdropScene.background = background;
2234
+ _backdropScene.backgroundBlurriness = renderScene.backgroundBlurriness;
2235
+ _backdropScene.backgroundIntensity = renderScene.backgroundIntensity;
2236
+ _backdropScene.backgroundRotation.copy(renderScene.backgroundRotation);
2237
+ const clearColorWas = renderer.autoClearColor;
2238
+ try {
2239
+ renderer.render(_backdropScene, _backdropCam);
2240
+ renderScene.background = null;
2241
+ renderer.autoClearColor = false;
2242
+ renderer.render(renderScene, cam);
2243
+ } finally {
2244
+ renderScene.background = background;
2245
+ renderer.autoClearColor = clearColorWas;
2246
+ _backdropScene.background = null; // hold no reference to a rig's texture
2247
+ }
2248
+ }
2249
+
1701
2250
  // --- render loop ----------------------------------------------------------
1702
2251
  // The tween is applied after controls.update() so the cue wins the frame, and
1703
2252
  // the frame listeners run before render so a playback frame draws its own pose.
@@ -1711,6 +2260,7 @@ export function createViewer(container, part) {
1711
2260
  activeCamera.position.fromArray(tw.position);
1712
2261
  controls.target.fromArray(tw.target);
1713
2262
  }
2263
+ checkAutoProjection();
1714
2264
  // Per-listener guard, because three re-arms requestAnimationFrame only AFTER
1715
2265
  // this callback returns (WebGLAnimation.onAnimationFrame): a listener that
1716
2266
  // throws would stop the rAF chain outright and freeze the viewer for good, not
@@ -1734,7 +2284,7 @@ export function createViewer(container, part) {
1734
2284
  const next = projectPoint([anchorDot.position.x, anchorDot.position.y, anchorDot.position.z]);
1735
2285
  if (anchorMoved(lastAnchor, next)) publishAnchor(next);
1736
2286
  }
1737
- renderer.render(scene, activeCamera);
2287
+ renderWithBackdrop(scene, activeCamera);
1738
2288
  cutaway.renderOverlay(renderer, activeCamera);
1739
2289
  }
1740
2290
  renderer.setAnimationLoop(renderFrame);
@@ -1797,10 +2347,28 @@ export function createViewer(container, part) {
1797
2347
  }
1798
2348
 
1799
2349
  // --- camera state (read/write for persistence; mount.js owns storage) -------
2350
+ // Under ortho, `pos` is the PERSPECTIVE-EQUIVALENT position: same view
2351
+ // direction, at the distance whose perspective frame shows what the ortho
2352
+ // frustum shows now (halfH / zoom — setProjection's own conversion). An
2353
+ // ortho zoom never moves the camera, so the raw position would carry none of
2354
+ // it, and setCameraState — which re-derives the frustum from the distance at
2355
+ // zoom 1 — would bring a remount back at the unzoomed size. Every reader of
2356
+ // this state (viewerState's carry-over, the reload-persisted camera) means
2357
+ // "what the user was looking at", so they all want the equivalent. The live
2358
+ // ortho camera's own position is viewer.camera.position.
1800
2359
  function getCameraState() {
2360
+ const t = controls.target;
2361
+ if (projectionMode === "orthographic") {
2362
+ const offset = activeCamera.position.clone().sub(t);
2363
+ if (offset.lengthSq() > 0) {
2364
+ const d = orthoEquivalentDistance();
2365
+ const p = offset.normalize().multiplyScalar(d).add(t);
2366
+ return { pos: [p.x, p.y, p.z], target: [t.x, t.y, t.z] };
2367
+ }
2368
+ }
1801
2369
  return {
1802
2370
  pos: [activeCamera.position.x, activeCamera.position.y, activeCamera.position.z],
1803
- target: [controls.target.x, controls.target.y, controls.target.z],
2371
+ target: [t.x, t.y, t.z],
1804
2372
  };
1805
2373
  }
1806
2374
  function setCameraState({ pos, target }) {
@@ -1808,7 +2376,7 @@ export function createViewer(container, part) {
1808
2376
  controls.target.set(target[0], target[1], target[2]);
1809
2377
  controls.update();
1810
2378
  // A saved pose carries an implied FRAMING, so the ortho frustum has to be
1811
- // re-derived from the restored distance. Without it, a reload in ortho comes
2379
+ // re-derived from the restored distance. Without it, a remount in ortho comes
1812
2380
  // back at the wrong zoom: the projection is restored during mount setup,
1813
2381
  // while the camera is restored much later (showView, on the first accepted
1814
2382
  // build), so the frustum would stay sized for wherever the camera happened
@@ -1822,7 +2390,18 @@ export function createViewer(container, part) {
1822
2390
  // that really does mean "refit" opts in with `{ refit: true }`, which runs
1823
2391
  // this same sync exactly once, on the tween's completion; the view cube's
1824
2392
  // clicks are the only callers that do.
1825
- if (projectionMode === "orthographic") syncOrthoToPerspectiveFraming();
2393
+ if (projectionMode === "orthographic") {
2394
+ // A placed pose is not a rotation, so it re-arms rather than exits — as
2395
+ // long as it is still a face view. Orthographic only ever holds on one
2396
+ // (the projection is automatic), so a pose off every axis — an old
2397
+ // stored state, a host restoring a free-orbit pose — comes back in
2398
+ // perspective: from the frustum just synced to this distance, a lossless
2399
+ // round trip.
2400
+ syncOrthoToPerspectiveFraming();
2401
+ const dir = activeCamera.position.clone().sub(controls.target);
2402
+ if (isFaceAligned(dir.toArray())) orthoAxis = dir;
2403
+ else setProjection("perspective");
2404
+ }
1826
2405
  }
1827
2406
  function onCameraEnd(cb) { controls.addEventListener("end", cb); }
1828
2407
 
@@ -1957,6 +2536,7 @@ export function createViewer(container, part) {
1957
2536
  controls.removeEventListener("start", onControlsStart);
1958
2537
  cameraStartListeners.clear();
1959
2538
  frameListeners.clear();
2539
+ assemblyListeners.clear();
1960
2540
  themeListeners.clear();
1961
2541
  projectionListeners.clear();
1962
2542
  canonicalCaptureHidden.clear();
@@ -2020,7 +2600,9 @@ export function createViewer(container, part) {
2020
2600
  captureCurrent,
2021
2601
  renderMeshPayloads,
2022
2602
  renderViews,
2603
+ renderStyleThumbnail,
2023
2604
  onFrame,
2605
+ onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
2024
2606
  tweenCameraTo,
2025
2607
  cancelCameraTween,
2026
2608
  orbitBy,
@@ -2034,7 +2616,7 @@ export function createViewer(container, part) {
2034
2616
  setCameraState,
2035
2617
  onCameraEnd,
2036
2618
  // A GETTER, not a value: the active camera changes when the projection is
2037
- // toggled, and every consumer (measure/dim3-scene.js, selection/raycast.js,
2619
+ // swapped, and every consumer (measure/dim3-scene.js, selection/raycast.js,
2038
2620
  // annotate/annotate-mode.js, measure/measure-mode.js) reads viewer.camera
2039
2621
  // fresh at call time — so this is transparent to all of them.
2040
2622
  get camera() { return activeCamera; },
@@ -2064,6 +2646,8 @@ export function createViewer(container, part) {
2064
2646
  registerCanonicalCaptureHidden,
2065
2647
  registerCaptureHidden,
2066
2648
  onCutawayHandleHover: cutaway.onHandleHoverChange,
2649
+ // The section changed: on, off, or the plane moved. Listener gets no args.
2650
+ onCutawayChange: cutaway.onChange,
2067
2651
  setRenderMode,
2068
2652
  getRenderMode: () => renderMode,
2069
2653
  onRenderModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
@@ -2078,6 +2662,8 @@ export function createViewer(container, part) {
2078
2662
  isRealisticPending: () => realisticPending,
2079
2663
  onEnvironmentChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
2080
2664
  setPrintFrames,
2665
+ setPrintFrameSource,
2666
+ invalidatePrintFrames,
2081
2667
  whenRealisticReady,
2082
2668
  dispose,
2083
2669
  };