partforge 0.123.0 → 0.124.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,9 +548,16 @@ 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;
@@ -530,15 +573,44 @@ export function createViewer(container, part) {
530
573
 
531
574
  // Anisotropic filtering: the ground disc and the wood grain are seen at grazing
532
575
  // angles, where plain trilinear mip selection blurs them into smeared blocks.
576
+ //
577
+ // TextureLoader.load returns at once and fills `image` later, so a rig (or a
578
+ // physical material) is "ready" long before its maps are: a capture taken
579
+ // straight away draws the floor and the wood untextured. Each load therefore
580
+ // records a settle promise (resolved on load AND on error — a missing map is
581
+ // drawn without it, never an error), and whenTexturesSettled() waits for them.
582
+ const pendingTextures = new Set();
533
583
  const loadTexture = (file) => {
534
584
  let t = textureCache.get(file);
535
585
  if (!t) {
536
- t = textureLoader.load(assetUrl(file));
586
+ let settle;
587
+ const settled = new Promise((resolve) => { settle = resolve; });
588
+ pendingTextures.add(settled);
589
+ settled.then(() => pendingTextures.delete(settled));
590
+ t = textureLoader.load(assetUrl(file), () => settle(), undefined, () => settle());
537
591
  t.anisotropy = Math.min(8, renderer.capabilities?.getMaxAnisotropy?.() ?? 1);
538
592
  textureCache.set(file, t);
539
593
  }
540
594
  return t;
541
595
  };
596
+ // Every texture requested so far has loaded or failed. Loops, because waiting
597
+ // can overlap more requests (a material built by a later compile). Bounded:
598
+ // an image request that never answers must not hang a mode switch or a
599
+ // capture forever — past the cap it draws with whatever has arrived.
600
+ const TEXTURE_SETTLE_CAP_MS = 15000;
601
+ async function whenTexturesSettled() {
602
+ const deadline = Date.now() + TEXTURE_SETTLE_CAP_MS;
603
+ while (pendingTextures.size) {
604
+ const left = deadline - Date.now();
605
+ if (left <= 0) return;
606
+ let timer;
607
+ await Promise.race([
608
+ Promise.all([...pendingTextures]),
609
+ new Promise((resolve) => { timer = setTimeout(resolve, left); }),
610
+ ]);
611
+ clearTimeout(timer);
612
+ }
613
+ }
542
614
  const loadHdr = (url) => new UltraHDRLoader().setDataType(THREE.HalfFloatType).loadAsync(url);
543
615
  function rigFor(id) {
544
616
  if (!rigCache.has(id)) {
@@ -552,6 +624,52 @@ export function createViewer(container, part) {
552
624
  return rigCache.get(id);
553
625
  }
554
626
 
627
+ // A rig loaded only to draw a capture (a thumbnail, or renderViews from CAD) is freed straight away: each holds
628
+ // its equirect (~16 MB of half-float) and a PMREM, and four of them resident
629
+ // on a phone is not acceptable. The live one (or one a switch is loading) stays
630
+ // — and only those: in CAD, the default environment is freed like any other.
631
+ // rig.dispose() also disposes the ground textures loadTexture cached — safe:
632
+ // each environment.js ground.texture/roughnessTexture/normalTexture is a
633
+ // distinct file (environments.js), so no two rigs ever share a Texture
634
+ // object, and Texture.dispose() only frees the GPU handle (via the
635
+ // renderer's 'dispose' listener) — it leaves texture.image and every field
636
+ // three's uploader reads untouched. loadTexture keeps returning that same
637
+ // (still-intact) Texture object out of textureCache, so a later rigFor for
638
+ // this id just re-uploads it on the next render, exactly like a first load.
639
+ //
640
+ // Concurrent captures of the same environment share one in-flight rig
641
+ // promise (rigFor's own cache), and a rig's envMap is a PMREM render-target
642
+ // texture that CANNOT re-upload once disposed — so releasing while a
643
+ // sibling call is still capturing with it would leave that capture's
644
+ // reflections black. EVERY async capture that awaits a rig must therefore
645
+ // borrow it through borrowRig and give it back through releaseRig in a
646
+ // `finally` (renderStyleThumbnail and renderViews both do). `p` is the
647
+ // exact promise borrowRig took from rigFor: `borrowedRigRefs` counts
648
+ // in-flight users of `id` (bumped synchronously in borrowRig, BEFORE any
649
+ // await, so an overlapping borrower is always already counted; decremented
650
+ // here), and this only deletes/
651
+ // disposes once that count reaches 0 AND `rigCache.get(id) === p` — the
652
+ // second check is what stops a call from freeing a *different* rig a later
653
+ // load (or the live environment) has since put in the cache for the same id.
654
+ function borrowRig(id) {
655
+ const p = rigFor(id);
656
+ borrowedRigRefs.set(id, (borrowedRigRefs.get(id) ?? 0) + 1);
657
+ return p;
658
+ }
659
+ function releaseRig(id, p) {
660
+ const refs = (borrowedRigRefs.get(id) ?? 0) - 1;
661
+ if (refs > 0) { borrowedRigRefs.set(id, refs); return; }
662
+ borrowedRigRefs.delete(id);
663
+ // Keep only a rig that is (or is about to be) on screen. In CAD the
664
+ // current environment's id alone is no reason: nothing is showing it, and
665
+ // a realistic switch reloads it (from the texture cache) when asked.
666
+ if (realisticRig?.id === id || (realisticPending && id === environmentId)) return;
667
+ if (rigCache.get(id) !== p) return;
668
+ rigCache.delete(id);
669
+ loadedRigs.delete(id);
670
+ p.then((r) => r.dispose(), () => {});
671
+ }
672
+
555
673
  // three's Material.copy carries neither onBeforeCompile nor
556
674
  // customProgramCacheKey, and it JSON-copies userData. For a patterned
557
675
  // physical material (patterns.js) that means every clone the cutaway or a
@@ -580,9 +698,19 @@ export function createViewer(container, part) {
580
698
  if (!m) {
581
699
  m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
582
700
  physicalMats.set(name, m);
701
+ syncGrain(name);
583
702
  }
584
703
  return m;
585
704
  }
705
+ // Wood grain runs along the sub-part's longest axis (patterns.js). The
706
+ // uniforms are shared with every clone, so setting them once reaches the
707
+ // cutaway's and the fades' copies too. Called when the material is built and
708
+ // whenever new geometry lands (a regen can change which axis is longest).
709
+ function syncGrain(name) {
710
+ const m = physicalMats.get(name);
711
+ const geo = subCache[name];
712
+ if (m && geo) setGrainAxis(m, grainAxisFor(geo.boundingBox));
713
+ }
586
714
 
587
715
  function publishMode(extra = {}) {
588
716
  const evt = { mode: renderMode, busy: false, error: null, ...extra };
@@ -682,7 +810,7 @@ export function createViewer(container, part) {
682
810
  // was still CAD, so setSubGeometry skipped the UVs. Idempotent.
683
811
  for (const n of names) if (subCache[n] && physicalFor(n).userData.pfAnisotropic) ensureBoxUVs(subCache[n]);
684
812
  setSubMaterials(physicalFor);
685
- if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
813
+ if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group, ...(realisticRig.lights ?? []));
686
814
  realisticRig = rig;
687
815
  scene.environment = rig.envMap;
688
816
  scene.background = rig.background;
@@ -690,13 +818,15 @@ export function createViewer(container, part) {
690
818
  scene.backgroundIntensity = rig.backgroundIntensity ?? 1;
691
819
  scene.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
692
820
  scene.environmentRotation.set(0, rig.rotationY ?? 0, 0);
693
- scene.add(rig.ground, rig.shadow.group);
821
+ scene.add(rig.ground, rig.shadow.group, ...(rig.lights ?? []));
694
822
  for (const l of Object.values(liveLights)) l.visible = false;
695
823
  grid.visible = false;
696
824
  renderer.toneMapping = THREE.NeutralToneMapping;
697
825
  renderer.toneMappingExposure = rig.exposure;
698
826
  if (reground) placeGround({ force: !live });
699
827
  if (live) applyPixelRatio("realistic");
828
+ // Hides the lines on every sub-part now that renderMode reads realistic.
829
+ refreshFeatureLines();
700
830
  } catch (e) {
701
831
  try { enterCad({ live }); } catch (rollback) { console.warn("partforge: rolling back to CAD failed", rollback); }
702
832
  throw e;
@@ -714,7 +844,7 @@ export function createViewer(container, part) {
714
844
  });
715
845
  renderMode = "cad";
716
846
  attempt(() => {
717
- if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
847
+ if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group, ...(realisticRig.lights ?? []));
718
848
  });
719
849
  realisticRig = null;
720
850
  shadowMovedAt = null;
@@ -730,6 +860,7 @@ export function createViewer(container, part) {
730
860
  attempt(() => { grid.visible = true; });
731
861
  for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
732
862
  if (live) attempt(() => applyPixelRatio("cad"));
863
+ attempt(refreshFeatureLines);
733
864
  if (firstError) throw firstError;
734
865
  }
735
866
 
@@ -795,6 +926,10 @@ export function createViewer(container, part) {
795
926
  if (isStale(token)) return renderMode;
796
927
  await compileRealistic(rig);
797
928
  if (isStale(token)) return renderMode;
929
+ // The ground's and materials' maps: without this the floor pops in a
930
+ // frame or two after the switch, untextured first.
931
+ await whenTexturesSettled();
932
+ if (isStale(token)) return renderMode;
798
933
  enterRealistic(rig);
799
934
  realisticPending = false;
800
935
  publishMode();
@@ -849,10 +984,12 @@ export function createViewer(container, part) {
849
984
  for (const p of rigCache.values()) p.then((r) => r.dispose(), () => {});
850
985
  rigCache.clear();
851
986
  loadedRigs.clear();
987
+ borrowedRigRefs.clear();
852
988
  for (const m of physicalMats.values()) m.dispose();
853
989
  physicalMats.clear();
854
990
  for (const t of textureCache.values()) t.dispose();
855
991
  textureCache.clear();
992
+ pendingTextures.clear();
856
993
  pmrem?.dispose();
857
994
  pmrem = null;
858
995
  }
@@ -864,6 +1001,10 @@ export function createViewer(container, part) {
864
1001
  const frameListeners = new Set();
865
1002
  function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
866
1003
 
1004
+ // Fired at the end of every showAssembly, for hosts that need to react to
1005
+ // which sub-parts are visible (e.g. re-deriving a view style thumbnail).
1006
+ const assemblyListeners = new Set();
1007
+
867
1008
  const camTween = createCameraTween();
868
1009
 
869
1010
  // OrbitControls' damping is a rotational RESIDUAL, not a per-frame effect: it
@@ -912,7 +1053,23 @@ export function createViewer(container, part) {
912
1053
  // the tween would re-zoom on every frame of a 0.6s cue (see setCameraState's
913
1054
  // comment, which is where that reasoning is written down). Composed with the
914
1055
  // caller's own onComplete rather than replacing it.
915
- function tweenCameraTo(viewName, { duration = 0.6, onComplete, refit = false } = {}) {
1056
+ //
1057
+ // `autoProjection` is the view cube's alone (its canvas clicks and its
1058
+ // per-face keyboard buttons): a tween that lands on a FACE view — straight
1059
+ // down a world axis — settles into orthographic at the end. Deliberately an
1060
+ // explicit option rather than inferred from the view name, because an
1061
+ // animation camera cue to "front" means "look from the front", not "switch
1062
+ // projection"; cues never pass it, so they never ENTER ortho.
1063
+ //
1064
+ // Leaving ortho is the same for every tween: one whose destination is not a
1065
+ // face view (an edge, a corner, iso) swaps back to perspective at the START.
1066
+ // At the start because the part is still seen head-on there, where the two
1067
+ // projections differ least, and the rotation that follows then reads as the
1068
+ // ordinary perspective orbit it is; swapping at the end instead would spin
1069
+ // the part flat and then pop the depth in at an oblique angle, where the
1070
+ // difference is largest. A tween between two faces while ortho stays ortho
1071
+ // the whole way — no projection change at all, rather than two in 0.6s.
1072
+ function tweenCameraTo(viewName, { duration = 0.6, onComplete, refit = false, autoProjection = false } = {}) {
916
1073
  const box = getVisibleWorldBounds();
917
1074
  if (!box || box.isEmpty()) { onComplete?.(); return; }
918
1075
  const center = box.getCenter(new THREE.Vector3()).toArray();
@@ -920,11 +1077,24 @@ export function createViewer(container, part) {
920
1077
  // radius = full max extent (not half), matching frameTo's framing distance so a
921
1078
  // live camera cue doesn't land twice as close as the reframe button and crop the part.
922
1079
  const pose = cameraPoseForView(viewName, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
1080
+ const toDir = new THREE.Vector3().fromArray(pose.position).sub(new THREE.Vector3().fromArray(pose.target));
1081
+ const toFace = isFaceAligned(toDir.toArray());
1082
+ if (!toFace && projectionMode === "orthographic") setProjection("perspective");
923
1083
  // 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.
1084
+ // enough for a host to have changed it under it.
925
1085
  const finish = () => {
926
1086
  restoreDamping();
927
- if (refit && projectionMode === "orthographic") {
1087
+ if (autoProjection && toFace && projectionMode !== "orthographic") {
1088
+ // Settle into the face view. The tween fires onComplete from inside its
1089
+ // own update(), BEFORE the render loop writes the final pose onto the
1090
+ // camera, so put it there first — the render loop then writes the same
1091
+ // values onto the (now orthographic) camera. setProjection sizes the
1092
+ // frustum from the camera's distance, which is the pose's framing
1093
+ // distance, so this is also the refit, with zoom 1.
1094
+ activeCamera.position.fromArray(pose.position);
1095
+ controls.target.fromArray(pose.target);
1096
+ setProjection("orthographic");
1097
+ } else if (refit && projectionMode === "orthographic") {
928
1098
  // The tween fires onComplete from inside its own update(), BEFORE the
929
1099
  // render loop writes the final pose onto the camera — so the camera is
930
1100
  // still a frame short of where it is going. Frame from the distance
@@ -932,8 +1102,14 @@ export function createViewer(container, part) {
932
1102
  syncOrthoToPerspectiveFraming({
933
1103
  distance: new THREE.Vector3().fromArray(pose.position)
934
1104
  .distanceTo(new THREE.Vector3().fromArray(pose.target)),
1105
+ target: new THREE.Vector3().fromArray(pose.target),
1106
+ direction: toDir,
935
1107
  });
936
1108
  }
1109
+ // Still ortho at the end (a face-to-face tween, a cue onto a face, or the
1110
+ // switch just above): the face this tween landed on is the axis a
1111
+ // rotation now has to leave.
1112
+ if (projectionMode === "orthographic") orthoAxis = toDir.clone();
937
1113
  onComplete?.();
938
1114
  };
939
1115
  suspendDamping();
@@ -997,20 +1173,189 @@ export function createViewer(container, part) {
997
1173
  orthoCamera.updateProjectionMatrix();
998
1174
  }
999
1175
 
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.
1176
+ // Where a projection swap matches size. The two projections agree on the
1177
+ // size of things at exactly ONE depth — an orthographic view draws every
1178
+ // depth at one scale, a perspective one magnifies whatever is nearer — so the
1179
+ // swap has to choose it, and it chooses the surface the user is looking AT:
1180
+ // the first visible surface under the screen centre, found by a ray down the
1181
+ // view axis through the orbit target. It used to be the target's own depth
1182
+ // (the part's CENTRE, for a view cube face), while a face view shows the
1183
+ // part's FRONT: the swap back to perspective then magnified the surface on
1184
+ // screen by distance / (distance - depth), and since that distance is the
1185
+ // ortho zoom recovered as a dolly, zooming in made the jump grow (a planter
1186
+ // zoomed 2.27x jumped 22% wider and 53% taller).
1187
+ //
1188
+ // Returned as the signed distance of that surface from the target, along
1189
+ // `towardCamera` (target -> camera). Positive is in front of the target.
1190
+ // - What is visible is what is DRAWN: an opaque sub-part's front face
1191
+ // that survives the cutaway, or — with the cutaway on — the section cap
1192
+ // where the ray crosses the plane inside solid material (the cap is not a
1193
+ // sub-part mesh, so the ray finds it by parity rather than by a hit).
1194
+ // Ghosted sub-parts (a static display opacity below 1) are seen through.
1195
+ // - `maxDepth` is where a PERSPECTIVE camera sits: it cannot see anything
1196
+ // behind itself (an orthographic one can — its near plane may be
1197
+ // negative, see depth-range.js — so the exit swap searches the whole
1198
+ // line). A surface within MATCH_CLEARANCE of the camera is no surface
1199
+ // to match at: the target's depth is kept instead.
1200
+ // - Nothing on the ray (a hole, a gap between bodies, an edge-on sheet)
1201
+ // falls back to the front of the visible bounds: in a face view, the
1202
+ // nearest face of the part.
1203
+ // - Nothing at all shown: 0, the target's depth, as before.
1204
+ const _matchRaycaster = new THREE.Raycaster();
1205
+ const _matchDir = new THREE.Vector3();
1206
+ const _matchOrigin = new THREE.Vector3();
1207
+ const _matchCorner = new THREE.Vector3();
1208
+ const _matchSphere = new THREE.Sphere();
1209
+ const _matchNormal = new THREE.Vector3();
1210
+ const _matchNormalMat = new THREE.Matrix3();
1211
+ const _matchPlane = new THREE.Plane();
1212
+ const _equivDir = new THREE.Vector3();
1213
+ // Both sides of every triangle: the parity walk has to see the faces the ray
1214
+ // LEAVES a solid through, which a FrontSide material hides from a raycast.
1215
+ const _matchProxy = new THREE.Mesh(undefined, new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }));
1216
+ _matchProxy.matrixAutoUpdate = false;
1217
+ const MATCH_CLEARANCE = 1e-3; // of the visible bounds' diagonal
1218
+ // The ray's candidate surfaces are remembered per RAY and SCENE: the target,
1219
+ // the direction, the cutaway's plane (null while off — so a toggle, drag,
1220
+ // flip or reset asks afresh), and a generation bumped by every geometry,
1221
+ // pose or visibility change (setSubGeometry, setSubPose, showAssembly,
1222
+ // hideAssembly) — a regenerated pocket or a hidden inner sub-part can move
1223
+ // the surface without moving the bounds. Swapping in and back out asks
1224
+ // about the same ray, but not bit-for-bit: controls.update() re-derives the
1225
+ // camera position from spherical coordinates on every swap, so the
1226
+ // direction comes back an ulp off — and a ray through a triangle edge (a
1227
+ // target at the centre of a symmetric part is exactly that) can hit on one
1228
+ // side of the ulp and miss on the other, which turned an untouched round
1229
+ // trip into a 25% reframe. What is remembered is the whole candidate list,
1230
+ // not one answer, so a query from a different camera position (`maxDepth`)
1231
+ // still gets its own, correct answer from it.
1232
+ let matchMemo = null; // { generation, target, dir, plane, candidates, fallback, clearance }
1233
+ const RAY_EPS = 1e-9;
1234
+ const samePlane = (a, b) => (a === null || b === null ? a === b
1235
+ : a.normal.distanceTo(b.normal) <= RAY_EPS && Math.abs(a.constant - b.constant) <= RAY_EPS * (1 + Math.abs(a.constant)));
1236
+ function sizeMatchDepth(target, towardCamera, { maxDepth = Infinity } = {}) {
1237
+ if (towardCamera.lengthSq() === 0) return 0;
1238
+ const box = getVisibleWorldBounds();
1239
+ if (box.isEmpty()) return 0;
1240
+ _matchDir.copy(towardCamera).normalize();
1241
+ const plane = cutaway.getPlane?.(_matchPlane) ?? null;
1242
+ const tol = RAY_EPS * (1 + box.max.distanceTo(box.min));
1243
+ let m = matchMemo;
1244
+ if (!(m && m.generation === matchGeneration && m.target.distanceTo(target) <= tol
1245
+ && m.dir.dot(_matchDir) >= 1 - RAY_EPS && samePlane(m.plane, plane))) {
1246
+ m = matchMemo = {
1247
+ generation: matchGeneration,
1248
+ target: target.clone(),
1249
+ dir: _matchDir.clone(),
1250
+ plane: plane && plane.clone(),
1251
+ ...matchCandidates(target, box, plane),
1252
+ };
1253
+ }
1254
+ // Candidates are nearest-the-camera first: the first one in front of the
1255
+ // camera, clear of it, is what the camera sees.
1256
+ for (const depth of m.candidates) {
1257
+ if (depth > maxDepth) continue; // behind a perspective camera
1258
+ return maxDepth - depth < m.clearance ? 0 : depth;
1259
+ }
1260
+ if (maxDepth - m.fallback < m.clearance) return 0;
1261
+ return m.fallback;
1262
+ }
1263
+ function matchCandidates(target, box, plane) {
1264
+ // Started outside everything shown rather than at the camera, so the ray
1265
+ // does not depend on where a perspective camera happens to be (an ortho
1266
+ // camera's position is a direction more than a place).
1267
+ box.getBoundingSphere(_matchSphere);
1268
+ const outside = target.distanceTo(_matchSphere.center) + _matchSphere.radius + 1;
1269
+ _matchOrigin.copy(target).addScaledVector(_matchDir, outside);
1270
+ const rayDir = _matchDir.clone().negate(); // camera -> target
1271
+ _matchRaycaster.set(_matchOrigin, rayDir);
1272
+ _matchRaycaster.far = 2 * outside;
1273
+ const hits = [];
1274
+ for (const n of names) {
1275
+ const mesh = subMesh[n];
1276
+ if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
1277
+ if ((part.parts[n].display?.opacity ?? 1) < 1) continue; // a ghost is seen through
1278
+ mesh.updateWorldMatrix(true, false);
1279
+ _matchProxy.geometry = mesh.geometry;
1280
+ _matchProxy.matrixWorld.copy(mesh.matrixWorld);
1281
+ _matchNormalMat.getNormalMatrix(mesh.matrixWorld);
1282
+ for (const hit of _matchRaycaster.intersectObject(_matchProxy, false)) {
1283
+ _matchNormal.copy(hit.face.normal).applyMatrix3(_matchNormalMat);
1284
+ hits.push({ t: hit.distance, point: hit.point, entering: _matchNormal.dot(rayDir) < 0 });
1285
+ }
1286
+ }
1287
+ _matchProxy.geometry = undefined;
1288
+ hits.sort((x, y) => x.t - y.t);
1289
+ // Where the ray crosses the cut plane, heading INTO the half that is kept:
1290
+ // a cap is drawn there if the crossing is inside solid material.
1291
+ let capT = null;
1292
+ if (plane) {
1293
+ const denom = plane.normal.dot(rayDir);
1294
+ if (denom > 0) {
1295
+ const t = -(plane.normal.dot(_matchOrigin) + plane.constant) / denom;
1296
+ if (t > 0) capT = t;
1297
+ }
1298
+ }
1299
+ const candidates = [];
1300
+ let inside = 0;
1301
+ let capDone = capT === null;
1302
+ for (const hit of hits) {
1303
+ if (!capDone && hit.t >= capT) {
1304
+ capDone = true;
1305
+ if (inside > 0) candidates.push(outside - capT);
1306
+ }
1307
+ if (hit.entering && (cutaway.isPointVisible?.(hit.point) ?? true)) candidates.push(outside - hit.t);
1308
+ inside += hit.entering ? 1 : -1;
1309
+ }
1310
+ let fallback = -Infinity;
1311
+ for (let i = 0; i < 8; i++) {
1312
+ _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);
1313
+ fallback = Math.max(fallback, _matchCorner.sub(target).dot(_matchDir));
1314
+ }
1315
+ return {
1316
+ candidates,
1317
+ fallback: Number.isFinite(fallback) ? fallback : 0,
1318
+ clearance: MATCH_CLEARANCE * box.max.distanceTo(box.min),
1319
+ };
1320
+ }
1321
+
1322
+ // The perspective distance (target -> camera) that shows what the ortho
1323
+ // camera shows now, at the size-match surface above. OrbitControls zooms an
1324
+ // ortho camera through camera.zoom rather than by moving it, which is why
1325
+ // this reads the zoom. Falls back to the target's depth if the surface is so
1326
+ // far BEHIND the target that the camera would have to sit behind the target
1327
+ // to match it (a target panned out in front of everything).
1328
+ function orthoEquivalentDistance() {
1329
+ const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
1330
+ // `|| 1` on the zoom for the same reason captureCurrent guards it: a zero
1331
+ // would make this non-finite.
1332
+ const atSurface = perspectiveDistance({ halfH, zoom: orthoCamera.zoom || 1, fovDeg: camera.fov });
1333
+ const depth = sizeMatchDepth(controls.target, _equivDir.copy(orthoCamera.position).sub(controls.target));
1334
+ const distance = atSurface + depth;
1335
+ return distance > atSurface * 0.05 ? distance : atSurface;
1336
+ }
1337
+
1338
+ // Re-derive the ortho frustum from the perspective camera's fov so that it
1339
+ // shows what a perspective camera at the CURRENT distance from the orbit
1340
+ // target shows at the size-match surface (sizeMatchDepth). Called on every
1341
+ // swap into ortho and after any reframe, which is what keeps the two
1342
+ // projections showing the same amount of part.
1004
1343
  //
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;
1344
+ // `distance` / `target` / `direction` override "current": tweenCameraTo's
1345
+ // refit runs from the tween's completion callback, which fires one frame
1346
+ // BEFORE the final pose reaches the camera, so it frames from the pose it
1347
+ // asked for rather than from where the camera happens to be at that instant.
1348
+ const _syncTarget = new THREE.Vector3();
1349
+ const _syncDir = new THREE.Vector3();
1350
+ function syncOrthoToPerspectiveFraming({ distance: atDistance, target, direction } = {}) {
1351
+ _syncTarget.copy(target ?? controls.target);
1352
+ if (direction) _syncDir.copy(direction);
1353
+ else _syncDir.copy(activeCamera.position).sub(_syncTarget);
1354
+ const distance = atDistance || _syncDir.length() || 1;
1355
+ const depth = sizeMatchDepth(_syncTarget, _syncDir, { maxDepth: distance });
1011
1356
  applyOrthoFrustum(orthoFrustum({
1012
1357
  fovDeg: camera.fov,
1013
- distance,
1358
+ distance: distance - depth,
1014
1359
  aspect: camera.aspect || 1,
1015
1360
  }));
1016
1361
  // The frustum now expresses the whole framing, so any dolly-by-zoom the user
@@ -1035,7 +1380,8 @@ export function createViewer(container, part) {
1035
1380
  } else {
1036
1381
  // Recover whatever dolly the user did while in ortho: OrbitControls
1037
1382
  // 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.
1383
+ // has to come back as a distance or the part jumps size — matched at the
1384
+ // surface the user is looking at (orthoEquivalentDistance).
1039
1385
  //
1040
1386
  // The bound exists because ortho zoom is UNBOUNDED and zooming a long way
1041
1387
  // out costs nothing there (an ortho projection has no depth falloff) —
@@ -1046,17 +1392,15 @@ export function createViewer(container, part) {
1046
1392
  // which is the camera that is still live and therefore the one
1047
1393
  // updateDepthRange has been keeping current. `far * 0.9` alone would be
1048
1394
  // 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.
1395
+ // part sits at 786mm and a plain swap would silently reframe it closer.
1050
1396
  // 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;
1397
+ // untouched round trip (zoom === 1, where the entry and this exit match at
1398
+ // the same surface and are exact inverses) lossless for a part of ANY
1399
+ // size, and still never lets a degenerate zoom move the camera further
1400
+ // out than it already was.
1057
1401
  const offset = from.position.clone().sub(controls.target);
1058
1402
  const distance = Math.min(
1059
- perspectiveDistance({ halfH, zoom: orthoCamera.zoom || 1, fovDeg: camera.fov }),
1403
+ orthoEquivalentDistance(),
1060
1404
  Math.max(from.far * 0.9, offset.length()),
1061
1405
  );
1062
1406
  camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
@@ -1080,12 +1424,16 @@ export function createViewer(container, part) {
1080
1424
  // controls.update() ends in Object3D.lookAt, which does refresh matrixWorld
1081
1425
  // — but it refreshes BEFORE writing the new quaternion, so a rotation
1082
1426
  // applied inside that same update (damping momentum still decaying as the
1083
- // toggle lands) leaves the rotation one frame behind. One matrix compose is
1427
+ // swap lands) leaves the rotation one frame behind. One matrix compose is
1084
1428
  // cheaper than depending on that ordering. Placed after controls.update()
1085
1429
  // for the same reason: it is the last writer of the pose.
1086
1430
  to.updateMatrixWorld();
1087
1431
  cutaway.setCamera(to);
1088
1432
  projectionMode = next;
1433
+ // Ortho is armed on the direction it was entered on, whoever entered it (a
1434
+ // face click, a host's runtime.projection.set, a restore): the first
1435
+ // rotation off it returns to perspective the same way for all of them.
1436
+ orthoAxis = next === "orthographic" ? to.position.clone().sub(controls.target) : null;
1089
1437
  for (const cb of [...projectionListeners]) cb(projectionMode);
1090
1438
  return projectionMode;
1091
1439
  }
@@ -1095,6 +1443,30 @@ export function createViewer(container, part) {
1095
1443
  return () => projectionListeners.delete(cb);
1096
1444
  }
1097
1445
 
1446
+ // Run every frame, after controls.update() and the tween have written the
1447
+ // pose: the first frame the view direction has turned off the ortho axis is a
1448
+ // ROTATION (a canvas drag, the cube's drag, a user grab that cancelled a tween
1449
+ // mid-turn), and it swaps back to perspective, size-preserving, from the
1450
+ // pose it has only just left. Direction rather than OrbitControls' "start"
1451
+ // event, because "start" also fires for pan and zoom, which keep the face
1452
+ // view. Here in the render loop rather than in a controls "change" listener
1453
+ // so setProjection's own controls.update() never runs nested inside one.
1454
+ // Skipped while a tween is in flight: it owns the camera, and decides the
1455
+ // projection itself (tweenCameraTo).
1456
+ // Allocation-free (it runs every frame while ortho): one scratch vector and
1457
+ // a dot product against the cosine of projection.js's epsilon — the same
1458
+ // test as hasLeftAxis, inlined.
1459
+ const _autoDir = new THREE.Vector3();
1460
+ const _axisUnit = new THREE.Vector3();
1461
+ function checkAutoProjection() {
1462
+ if (projectionMode !== "orthographic" || !orthoAxis || camTween.isActive()) return;
1463
+ _autoDir.copy(activeCamera.position).sub(controls.target);
1464
+ const len = _autoDir.length(), axisLen = orthoAxis.length();
1465
+ if (len === 0 || axisLen === 0) return;
1466
+ _axisUnit.copy(orthoAxis).divideScalar(axisLen);
1467
+ if (_autoDir.dot(_axisUnit) / len < FACE_ALIGN_COS) setProjection("perspective");
1468
+ }
1469
+
1098
1470
  // Fallback creasing for payloads with no kernel normals. Both backends now
1099
1471
  // ship authoritative normals (Manifold: policy-aware crease pass; OCCT:
1100
1472
  // analytic B-rep normals), so this path is last-ditch only — it must not be
@@ -1137,12 +1509,14 @@ export function createViewer(container, part) {
1137
1509
  const subCache = Object.fromEntries(names.map((n) => [n, null]));
1138
1510
 
1139
1511
  function setSubGeometry(name, payload) {
1512
+ matchGeneration++;
1140
1513
  setSubPose(name, null); // fresh worker mesh is baked at current params — clear any fast-path pose
1141
1514
  const prev = subCache[name];
1142
1515
  const next = buildGeometry(payload);
1143
1516
  // Brushed metal needs UVs for its tangent frame; CAD meshes carry none.
1144
1517
  if (renderMode === "realistic" && physicalMats.get(name)?.userData.pfAnisotropic) ensureBoxUVs(next);
1145
1518
  subCache[name] = next;
1519
+ syncGrain(name);
1146
1520
  // Section helpers must stop referring to the old buffers before those
1147
1521
  // buffers are released.
1148
1522
  cutaway.updateGeometry(name, next);
@@ -1154,6 +1528,7 @@ export function createViewer(container, part) {
1154
1528
  // three.js Matrix4 convention). Never affects exports or geometry — the worker
1155
1529
  // owns real placement; this only re-poses the delivered mesh.
1156
1530
  function setSubPose(name, mat16) {
1531
+ matchGeneration++;
1157
1532
  for (const obj of [subMesh[name], subLines[name]]) {
1158
1533
  if (!obj) continue;
1159
1534
  obj.matrixAutoUpdate = false;
@@ -1173,6 +1548,7 @@ export function createViewer(container, part) {
1173
1548
  // --- show / hide assembly -------------------------------------------------
1174
1549
  const _box = new THREE.Box3();
1175
1550
  const _posedBox = new THREE.Box3();
1551
+ const _frameDir = new THREE.Vector3();
1176
1552
 
1177
1553
  // Recentre the assembly on the pivot and frame the camera to the named parts.
1178
1554
  // Cached bounding boxes are in the delivered mesh's own frame, so any fast-path
@@ -1193,7 +1569,16 @@ export function createViewer(container, part) {
1193
1569
  floorY = -size.z / 2;
1194
1570
  grid.position.y = floorY;
1195
1571
  const r = Math.max(size.x, size.y, size.z) || 12;
1196
- activeCamera.position.setLength(r * 2.6 + 6);
1572
+ // Keep the VIEW DIRECTION (target -> camera), not the camera's direction
1573
+ // from the origin: after a pan the target is off the origin, and scaling
1574
+ // the position about the origin would turn the view — which, in an ortho
1575
+ // face view, reads as a rotation and drops to perspective at an off-axis
1576
+ // angle on the next frame. Falls back to the old origin-relative direction
1577
+ // for a degenerate offset (camera on its target).
1578
+ _frameDir.copy(activeCamera.position).sub(controls.target);
1579
+ if (_frameDir.lengthSq() === 0) _frameDir.copy(activeCamera.position);
1580
+ if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
1581
+ activeCamera.position.copy(_frameDir.normalize().multiplyScalar(r * 2.6 + 6));
1197
1582
  controls.target.set(0, 0, 0);
1198
1583
  // Framing under ortho is a frustum, not a distance — without this the
1199
1584
  // reframe button moves the camera and nothing visibly changes.
@@ -1204,6 +1589,7 @@ export function createViewer(container, part) {
1204
1589
  // frame the camera to them — done only on the initial show and on view (tab)
1205
1590
  // changes, NOT on regeneration, so a user's zoom/orbit survives editing params.
1206
1591
  function showAssembly(visibleNames, { frame = false } = {}) {
1592
+ matchGeneration++;
1207
1593
  lastShown = [...visibleNames];
1208
1594
  for (const name of names) {
1209
1595
  if (visibleNames.includes(name)) {
@@ -1218,6 +1604,9 @@ export function createViewer(container, part) {
1218
1604
  if (frame) frameTo(visibleNames);
1219
1605
  cutaway.setVisible(effectiveVisible());
1220
1606
  placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
1607
+ for (const cb of [...assemblyListeners]) {
1608
+ try { cb(); } catch (e) { console.warn("partforge: assembly listener failed", e); }
1609
+ }
1221
1610
  }
1222
1611
 
1223
1612
  // Re-frame whatever is currently visible (the reframe button).
@@ -1273,6 +1662,7 @@ export function createViewer(container, part) {
1273
1662
  }
1274
1663
 
1275
1664
  function hideAssembly() {
1665
+ matchGeneration++;
1276
1666
  lastShown = [];
1277
1667
  for (const m of Object.values(subMesh)) m.visible = false;
1278
1668
  for (const l of Object.values(subLines)) l.visible = false;
@@ -1491,7 +1881,8 @@ export function createViewer(container, part) {
1491
1881
  // renders, and the editor's on-screen mode must never change what the agent
1492
1882
  // sees. A realistic live view lends its scene to CAD for the synchronous
1493
1883
  // capture and gets it back exactly (captureIn). renderViews is the one way
1494
- // to ask for realistic canonical views.
1884
+ // to ask for realistic canonical views. Feature lines are CAD-only, so
1885
+ // borrowing CAD for this capture draws them for free.
1495
1886
  function captureCanonicalViews(viewNames) {
1496
1887
  if (disposed) return [];
1497
1888
  const box = getVisibleWorldBounds();
@@ -1598,20 +1989,75 @@ export function createViewer(container, part) {
1598
1989
  }
1599
1990
  }
1600
1991
 
1992
+ // One small render of the CURRENT framing in any style, for the view style
1993
+ // popover. The live view is put back exactly (same contract as captureIn:
1994
+ // no publish, nothing persisted), whether it is CAD, or realistic in this
1995
+ // or another environment. The borrowed style brings its own feature-lines
1996
+ // visibility with it for free, since it's decided by render mode alone.
1997
+ async function renderStyleThumbnail(style, { size = 256 } = {}) {
1998
+ if (disposed) return null;
1999
+ const box = getVisibleWorldBounds();
2000
+ if (!box || box.isEmpty()) return null;
2001
+ const capture = () => currentFramingInCurrentLook({ size, quality: 0.8 });
2002
+ if (style === "cad") return captureIn("cad", null, capture);
2003
+ const id = resolveEnvironmentId(style).id;
2004
+ // Borrowed BEFORE awaiting (see releaseRig).
2005
+ const p = borrowRig(id);
2006
+ try {
2007
+ const rig = await p;
2008
+ if (disposed) return null;
2009
+ await compileRealistic(rig, { forCapture: true });
2010
+ if (disposed) return null;
2011
+ await whenTexturesSettled();
2012
+ if (disposed) return null;
2013
+ if (renderMode !== "realistic" || realisticRig === rig) return captureIn("realistic", rig, capture);
2014
+ // Realistic in another environment: borrow this rig, then put the live one back.
2015
+ const liveRig = realisticRig, movedAt = shadowMovedAt;
2016
+ try {
2017
+ enterRealistic(rig, { live: false });
2018
+ return capture();
2019
+ } finally {
2020
+ try {
2021
+ enterRealistic(liveRig, { live: false, reground: false });
2022
+ shadowMovedAt = movedAt;
2023
+ } catch (e) {
2024
+ // enterRealistic already rolled the scene back to CAD: make it the live mode.
2025
+ console.warn("partforge: restoring the realistic view after a thumbnail failed", e);
2026
+ try { applyPixelRatio("cad"); } catch { /* the view is CAD either way */ }
2027
+ publishMode({ error: "couldn't load realistic view" });
2028
+ }
2029
+ }
2030
+ } finally {
2031
+ releaseRig(id, p);
2032
+ }
2033
+ }
2034
+
1601
2035
  // Agent-facing canonical renders in a chosen appearance, whatever the live
1602
2036
  // view shows. CAD is exactly captureCanonicalViews (which is always CAD).
1603
2037
  // Realistic waits for the current environment's rig and the capture's
1604
2038
  // shader programs, then borrows the realistic look for the synchronous
1605
2039
  // capture only if the live view is not already realistic. A rig that fails
1606
- // to load rejects rather than silently returning CAD images.
2040
+ // to load rejects rather than silently returning CAD images. Feature lines
2041
+ // are CAD-only, so borrowing the realistic look draws without them for free.
1607
2042
  async function renderViews(viewNames, { renderMode: want = "cad" } = {}) {
1608
2043
  if (disposed) return [];
1609
2044
  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));
2045
+ // Borrowed like a thumbnail's rig, so a thumbnail of the same environment
2046
+ // finishing first cannot dispose it mid-capture (and, in CAD, it is freed
2047
+ // once this is done — it is not on screen).
2048
+ const id = environmentId;
2049
+ const p = borrowRig(id);
2050
+ try {
2051
+ const rig = await p;
2052
+ if (disposed) return [];
2053
+ await compileRealistic(rig, { forCapture: true });
2054
+ if (disposed) return [];
2055
+ await whenTexturesSettled();
2056
+ if (disposed) return [];
2057
+ return captureIn("realistic", rig, () => canonicalViewsInCurrentLook(viewNames));
2058
+ } finally {
2059
+ releaseRig(id, p);
2060
+ }
1615
2061
  }
1616
2062
 
1617
2063
  // Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
@@ -1676,7 +2122,7 @@ export function createViewer(container, part) {
1676
2122
  try {
1677
2123
  // fov comes from the PERSPECTIVE camera, deliberately, not from whichever
1678
2124
  // camera is live: thumbnails are canonical captures and stay perspective
1679
- // however the user has the projection toggled. cameraPoseForView's distance
2125
+ // whichever projection is live. cameraPoseForView's distance
1680
2126
  // is tuned to this fov, so a narrower one would crop long, thin parts.
1681
2127
  return renderOffscreen(
1682
2128
  pose,
@@ -1711,6 +2157,7 @@ export function createViewer(container, part) {
1711
2157
  activeCamera.position.fromArray(tw.position);
1712
2158
  controls.target.fromArray(tw.target);
1713
2159
  }
2160
+ checkAutoProjection();
1714
2161
  // Per-listener guard, because three re-arms requestAnimationFrame only AFTER
1715
2162
  // this callback returns (WebGLAnimation.onAnimationFrame): a listener that
1716
2163
  // throws would stop the rAF chain outright and freeze the viewer for good, not
@@ -1797,10 +2244,28 @@ export function createViewer(container, part) {
1797
2244
  }
1798
2245
 
1799
2246
  // --- camera state (read/write for persistence; mount.js owns storage) -------
2247
+ // Under ortho, `pos` is the PERSPECTIVE-EQUIVALENT position: same view
2248
+ // direction, at the distance whose perspective frame shows what the ortho
2249
+ // frustum shows now (halfH / zoom — setProjection's own conversion). An
2250
+ // ortho zoom never moves the camera, so the raw position would carry none of
2251
+ // it, and setCameraState — which re-derives the frustum from the distance at
2252
+ // zoom 1 — would bring a remount back at the unzoomed size. Every reader of
2253
+ // this state (viewerState's carry-over, the reload-persisted camera) means
2254
+ // "what the user was looking at", so they all want the equivalent. The live
2255
+ // ortho camera's own position is viewer.camera.position.
1800
2256
  function getCameraState() {
2257
+ const t = controls.target;
2258
+ if (projectionMode === "orthographic") {
2259
+ const offset = activeCamera.position.clone().sub(t);
2260
+ if (offset.lengthSq() > 0) {
2261
+ const d = orthoEquivalentDistance();
2262
+ const p = offset.normalize().multiplyScalar(d).add(t);
2263
+ return { pos: [p.x, p.y, p.z], target: [t.x, t.y, t.z] };
2264
+ }
2265
+ }
1801
2266
  return {
1802
2267
  pos: [activeCamera.position.x, activeCamera.position.y, activeCamera.position.z],
1803
- target: [controls.target.x, controls.target.y, controls.target.z],
2268
+ target: [t.x, t.y, t.z],
1804
2269
  };
1805
2270
  }
1806
2271
  function setCameraState({ pos, target }) {
@@ -1808,7 +2273,7 @@ export function createViewer(container, part) {
1808
2273
  controls.target.set(target[0], target[1], target[2]);
1809
2274
  controls.update();
1810
2275
  // 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
2276
+ // re-derived from the restored distance. Without it, a remount in ortho comes
1812
2277
  // back at the wrong zoom: the projection is restored during mount setup,
1813
2278
  // while the camera is restored much later (showView, on the first accepted
1814
2279
  // build), so the frustum would stay sized for wherever the camera happened
@@ -1822,7 +2287,18 @@ export function createViewer(container, part) {
1822
2287
  // that really does mean "refit" opts in with `{ refit: true }`, which runs
1823
2288
  // this same sync exactly once, on the tween's completion; the view cube's
1824
2289
  // clicks are the only callers that do.
1825
- if (projectionMode === "orthographic") syncOrthoToPerspectiveFraming();
2290
+ if (projectionMode === "orthographic") {
2291
+ // A placed pose is not a rotation, so it re-arms rather than exits — as
2292
+ // long as it is still a face view. Orthographic only ever holds on one
2293
+ // (the projection is automatic), so a pose off every axis — an old
2294
+ // stored state, a host restoring a free-orbit pose — comes back in
2295
+ // perspective: from the frustum just synced to this distance, a lossless
2296
+ // round trip.
2297
+ syncOrthoToPerspectiveFraming();
2298
+ const dir = activeCamera.position.clone().sub(controls.target);
2299
+ if (isFaceAligned(dir.toArray())) orthoAxis = dir;
2300
+ else setProjection("perspective");
2301
+ }
1826
2302
  }
1827
2303
  function onCameraEnd(cb) { controls.addEventListener("end", cb); }
1828
2304
 
@@ -1957,6 +2433,7 @@ export function createViewer(container, part) {
1957
2433
  controls.removeEventListener("start", onControlsStart);
1958
2434
  cameraStartListeners.clear();
1959
2435
  frameListeners.clear();
2436
+ assemblyListeners.clear();
1960
2437
  themeListeners.clear();
1961
2438
  projectionListeners.clear();
1962
2439
  canonicalCaptureHidden.clear();
@@ -2020,7 +2497,9 @@ export function createViewer(container, part) {
2020
2497
  captureCurrent,
2021
2498
  renderMeshPayloads,
2022
2499
  renderViews,
2500
+ renderStyleThumbnail,
2023
2501
  onFrame,
2502
+ onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
2024
2503
  tweenCameraTo,
2025
2504
  cancelCameraTween,
2026
2505
  orbitBy,
@@ -2034,7 +2513,7 @@ export function createViewer(container, part) {
2034
2513
  setCameraState,
2035
2514
  onCameraEnd,
2036
2515
  // 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,
2516
+ // swapped, and every consumer (measure/dim3-scene.js, selection/raycast.js,
2038
2517
  // annotate/annotate-mode.js, measure/measure-mode.js) reads viewer.camera
2039
2518
  // fresh at call time — so this is transparent to all of them.
2040
2519
  get camera() { return activeCamera; },