partforge 0.71.0 → 0.73.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,8 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
6
6
  import { LineMaterial } from "three/addons/lines/LineMaterial.js";
7
7
  import { createCutaway } from "./cutaway.js";
8
8
  import { createCameraTween } from "./camera-tween.js";
9
+ import { orbitPose } from "./camera-orbit.js";
10
+ import { orthoFrustum, perspectiveDistance } from "./projection.js";
9
11
  import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
10
12
  import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
11
13
 
@@ -98,12 +100,19 @@ export function thumbnailBackground(background = THUMBNAIL_BG) {
98
100
  // follows the live camera's aspect so the capture matches what the user framed.
99
101
  export function captureCurrentFromScene(
100
102
  { size = 2048, hideGrid = true, quality = 0.9 } = {},
101
- { renderer, liveCamera, target, grid, maxTextureSize },
103
+ { renderer, liveCamera, target, grid, maxTextureSize, projection = "perspective", orthoHalfH },
102
104
  ) {
103
105
  const MIN_SIZE = 256;
104
106
  // WebGL2 guarantees MAX_TEXTURE_SIZE >= 2048; only trust a larger reported cap.
105
107
  const long = Math.min(Math.max(Math.round(size) || MIN_SIZE, MIN_SIZE), maxTextureSize ?? 2048);
106
- const aspect = liveCamera.aspect || 1;
108
+ // An OrthographicCamera has no `aspect` its aspect lives in the frustum. Read
109
+ // it there, or the capture comes back SQUARE from a wide viewport the moment the
110
+ // user toggles to ortho: silent, and only wrong in the saved image.
111
+ const aspect = liveCamera.aspect
112
+ || (liveCamera.isOrthographicCamera
113
+ ? (liveCamera.right - liveCamera.left) / (liveCamera.top - liveCamera.bottom)
114
+ : 0)
115
+ || 1;
107
116
  const width = aspect >= 1 ? long : Math.max(1, Math.round(long * aspect));
108
117
  const height = aspect >= 1 ? Math.max(1, Math.round(long / aspect)) : long;
109
118
  const before = liveCamera.position.clone();
@@ -112,7 +121,10 @@ export function captureCurrentFromScene(
112
121
  try {
113
122
  return renderer.renderOffscreen(
114
123
  { position: liveCamera.position.toArray(), up: liveCamera.up.toArray(), target },
115
- { width, height, fov: liveCamera.fov, quality },
124
+ // fov is meaningless under an ortho camera; orthoHalfH replaces it. The
125
+ // CANONICAL capture path deliberately never passes either — agent-facing
126
+ // renders stay perspective regardless of what the user is looking at.
127
+ { width, height, fov: liveCamera.fov ?? 45, quality, projection, orthoHalfH },
116
128
  );
117
129
  } finally {
118
130
  if (grid && hideGrid) grid.visible = gridWasVisible;
@@ -146,10 +158,18 @@ export function createViewer(container, part) {
146
158
  const themeListeners = new Set();
147
159
  function onThemeChange(cb) { themeListeners.add(cb); return () => themeListeners.delete(cb); }
148
160
 
161
+ // Two cameras, one active. The perspective camera stays the source of truth
162
+ // for fov and aspect; the ortho camera borrows both through projection.js so
163
+ // a toggle never changes the part's size on screen.
149
164
  const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
150
165
  camera.position.set(18, 12, 18);
166
+ const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
167
+ orthoCamera.position.copy(camera.position);
168
+ let activeCamera = camera;
169
+ let projectionMode = "perspective";
170
+ const projectionListeners = new Set();
151
171
 
152
- const controls = new OrbitControls(camera, renderer.domElement);
172
+ const controls = new OrbitControls(activeCamera, renderer.domElement);
153
173
  controls.enableDamping = true;
154
174
 
155
175
  // --- lights + grid --------------------------------------------------------
@@ -349,7 +369,7 @@ export function createViewer(container, part) {
349
369
  const cutaway = createCutaway({
350
370
  renderer,
351
371
  scene,
352
- camera,
372
+ camera: activeCamera, // kept current across a projection swap via cutaway.setCamera
353
373
  orbitControls: controls,
354
374
  domElement: renderer.domElement,
355
375
  getBounds: getVisibleWorldBounds,
@@ -367,9 +387,54 @@ export function createViewer(container, part) {
367
387
  function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
368
388
 
369
389
  const camTween = createCameraTween();
390
+
391
+ // OrbitControls' damping is a rotational RESIDUAL, not a per-frame effect: it
392
+ // keeps applying a decaying fraction of the last drag's accumulated
393
+ // sphericalDelta on every update(), for seconds after the pointer went up.
394
+ // That is invisible DURING a cue tween — the tween writes position after
395
+ // controls.update() every frame, so whatever the residual did that frame is
396
+ // overwritten — but the residual is still unspent when the tween ends, and
397
+ // controls.update() then keeps walking the camera off the exact angle it just
398
+ // landed on. Measured on the demo part: a `top` click straight after a flick
399
+ // settled 3.8° off axis, where the same click from rest settled on it.
400
+ //
401
+ // Suspending damping is what drains it: with the flag off, update() applies
402
+ // the whole remaining sphericalDelta once and then zeroes it (same for
403
+ // panOffset), and that frame's position and target are overwritten by the
404
+ // tween anyway, so the drain never reaches the screen. The previous value is
405
+ // restored when the tween finishes OR is cancelled, so a user grab mid-tween —
406
+ // which cancels through beginCameraGrab — damps exactly as it always did.
407
+ let dampingBeforeTween = null;
408
+ function suspendDamping() {
409
+ if (dampingBeforeTween !== null) return; // already suspended; don't shadow the real value
410
+ dampingBeforeTween = controls.enableDamping;
411
+ controls.enableDamping = false;
412
+ }
413
+ function restoreDamping() {
414
+ if (dampingBeforeTween === null) return;
415
+ controls.enableDamping = dampingBeforeTween;
416
+ dampingBeforeTween = null;
417
+ }
418
+
370
419
  // Tween the orbit camera to a canonical angle, framed on what's visible now.
371
420
  // Presentational only; a caller passing duration 0 gets a jump cut.
372
- function tweenCameraTo(viewName, { duration = 0.6, onComplete } = {}) {
421
+ //
422
+ // `refit` opts in to also restoring the FRAMING at the end of the tween, and
423
+ // only matters under orthographic: the pose above already re-derives the
424
+ // framing distance from the visible bounds, which is the whole job in
425
+ // perspective, but under ortho apparent size comes from the frustum and
426
+ // camera.zoom rather than from distance, so a tween alone leaves whatever
427
+ // dolly the user had accumulated in place. Off by default, because "look from
428
+ // this direction" and "refit the part" are different intentions: an animation
429
+ // camera cue means only the former, and refitting mid-animation would resize
430
+ // the part under the user. The view cube's clicks mean both — clicking a face
431
+ // is the reframe button's job now — so they pass it.
432
+ //
433
+ // Applied ONCE, on completion, never per frame: re-deriving the frustum inside
434
+ // the tween would re-zoom on every frame of a 0.6s cue (see setCameraState's
435
+ // comment, which is where that reasoning is written down). Composed with the
436
+ // caller's own onComplete rather than replacing it.
437
+ function tweenCameraTo(viewName, { duration = 0.6, onComplete, refit = false } = {}) {
373
438
  const box = getVisibleWorldBounds();
374
439
  if (!box || box.isEmpty()) { onComplete?.(); return; }
375
440
  const center = box.getCenter(new THREE.Vector3()).toArray();
@@ -377,21 +442,173 @@ export function createViewer(container, part) {
377
442
  // radius = full max extent (not half), matching frameTo's framing distance so a
378
443
  // live camera cue doesn't land twice as close as the reframe button and crop the part.
379
444
  const pose = cameraPoseForView(viewName, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
445
+ // The projection is read at COMPLETION, not now: a 0.6s tween is long
446
+ // enough for the user to have toggled projection under it.
447
+ const finish = () => {
448
+ restoreDamping();
449
+ if (refit && projectionMode === "orthographic") {
450
+ // The tween fires onComplete from inside its own update(), BEFORE the
451
+ // render loop writes the final pose onto the camera — so the camera is
452
+ // still a frame short of where it is going. Frame from the distance
453
+ // this pose asked for rather than from wherever it has got to.
454
+ syncOrthoToPerspectiveFraming({
455
+ distance: new THREE.Vector3().fromArray(pose.position)
456
+ .distanceTo(new THREE.Vector3().fromArray(pose.target)),
457
+ });
458
+ }
459
+ onComplete?.();
460
+ };
461
+ suspendDamping();
380
462
  camTween.start(
381
- { position: camera.position.toArray(), target: controls.target.toArray() },
463
+ { position: activeCamera.position.toArray(), target: controls.target.toArray() },
382
464
  { position: pose.position, target: pose.target },
383
- { duration, onComplete },
465
+ { duration, onComplete: finish },
384
466
  );
385
467
  }
386
- const cancelCameraTween = () => camTween.cancel();
468
+ // Cancelling has to put damping back: the tween's own completion path is the
469
+ // only other place that does, and it never runs for a cancelled tween.
470
+ const cancelCameraTween = () => { camTween.cancel(); restoreDamping(); };
387
471
 
388
472
  // User grabbing the orbit cancels any cue tween (the user owns the camera) and
389
473
  // tells subscribers (the animation driver disarms remaining cues).
390
474
  const cameraStartListeners = new Set();
391
- const onControlsStart = () => { camTween.cancel(); for (const cb of [...cameraStartListeners]) cb(); };
475
+ // What every real camera grab owes its subscribers: an in-flight cue tween is
476
+ // cancelled, and the animation driver hears about it so remaining cues disarm.
477
+ // OrbitControls' "start" event gives the canvas this for free; an external drag
478
+ // source (the view cube) has to say so explicitly — so both routes call here
479
+ // rather than each keeping its own copy of the contract. A hoisted function
480
+ // declaration, not a `const` arrow: it runs after `cameraStartListeners` and
481
+ // `camTween` above are initialized, but nothing requires it be declared after
482
+ // them textually.
483
+ function beginCameraGrab() {
484
+ cancelCameraTween();
485
+ for (const cb of [...cameraStartListeners]) cb();
486
+ }
487
+ // beginCameraGrab takes no parameters, so the "start" event object
488
+ // OrbitControls passes in is simply ignored — safe to wire up directly.
489
+ const onControlsStart = beginCameraGrab;
392
490
  controls.addEventListener("start", onControlsStart);
393
491
  function onCameraStart(cb) { cameraStartListeners.add(cb); return () => cameraStartListeners.delete(cb); }
394
492
 
493
+ // Orbit from a pixel delta — the view cube's drag. Routed through the viewer
494
+ // rather than done in the widget so it gets the same beginCameraGrab contract
495
+ // that grabbing the canvas gets for free from OrbitControls' "start" event.
496
+ function orbitBy(dx, dy) {
497
+ beginCameraGrab();
498
+ const next = orbitPose(
499
+ {
500
+ position: activeCamera.position.toArray(),
501
+ target: controls.target.toArray(),
502
+ up: activeCamera.up.toArray(),
503
+ },
504
+ { dx, dy },
505
+ // Match OrbitControls' own feel: a drag spanning the full viewport height
506
+ // is a full turn, so the cube and the canvas rotate at the same rate.
507
+ { radiansPerPx: (2 * Math.PI) / Math.max(1, container.clientHeight || 1) },
508
+ );
509
+ activeCamera.position.fromArray(next.position);
510
+ controls.update();
511
+ }
512
+
513
+ // --- projection (perspective <-> orthographic) ------------------------------
514
+ function applyOrthoFrustum({ halfW, halfH }) {
515
+ orthoCamera.left = -halfW;
516
+ orthoCamera.right = halfW;
517
+ orthoCamera.top = halfH;
518
+ orthoCamera.bottom = -halfH;
519
+ orthoCamera.updateProjectionMatrix();
520
+ }
521
+
522
+ // Re-derive the ortho frustum from the perspective camera's fov at the
523
+ // camera's CURRENT distance from the orbit target. Called on every swap into
524
+ // ortho and after any reframe, which is what keeps the two projections
525
+ // showing the same amount of part.
526
+ //
527
+ // `distance` overrides "current": tweenCameraTo's refit runs from the tween's
528
+ // completion callback, which fires one frame BEFORE the final pose reaches the
529
+ // camera, so it frames from the distance it asked for rather than from where
530
+ // the camera happens to be at that instant.
531
+ function syncOrthoToPerspectiveFraming({ distance: atDistance } = {}) {
532
+ const distance = atDistance || activeCamera.position.distanceTo(controls.target) || 1;
533
+ applyOrthoFrustum(orthoFrustum({
534
+ fovDeg: camera.fov,
535
+ distance,
536
+ aspect: camera.aspect || 1,
537
+ }));
538
+ // The frustum now expresses the whole framing, so any dolly-by-zoom the user
539
+ // had accumulated is already spent — leaving it would double-count.
540
+ orthoCamera.zoom = 1;
541
+ orthoCamera.updateProjectionMatrix();
542
+ }
543
+
544
+ // Swap which camera is live. Everything downstream reads viewer.camera fresh
545
+ // at call time, so the only wiring that has to move is OrbitControls' own
546
+ // object and the cutaway's captured reference.
547
+ function setProjection(mode) {
548
+ const next = mode === "orthographic" ? "orthographic" : "perspective";
549
+ if (next === projectionMode) return projectionMode;
550
+ const from = activeCamera;
551
+ const to = next === "orthographic" ? orthoCamera : camera;
552
+ to.position.copy(from.position);
553
+ to.up.copy(from.up);
554
+ to.quaternion.copy(from.quaternion);
555
+ if (next === "orthographic") {
556
+ syncOrthoToPerspectiveFraming();
557
+ } else {
558
+ // Recover whatever dolly the user did while in ortho: OrbitControls
559
+ // changes camera.zoom there rather than moving the camera, so the zoom
560
+ // has to come back as a distance or the part jumps size.
561
+ //
562
+ // The bound exists because ortho zoom is UNBOUNDED and zooming a long way
563
+ // out costs nothing there (an ortho projection has no depth falloff) —
564
+ // while the recovered distance goes as 1/zoom, so a zoom near nothing would
565
+ // fling the perspective camera past its own far plane and blank the viewer
566
+ // with no cue as to why. `far * 0.9` alone would be too eager: frameTo
567
+ // frames at 2.6r + 6 MILLIMETRES, so an everyday 300mm part sits at 786mm
568
+ // and a plain toggle would silently reframe it closer. Hence the max with
569
+ // the distance the camera is already at, which makes an untouched round
570
+ // trip (zoom === 1, where orthoFrustum/perspectiveDistance are exact
571
+ // inverses) lossless for a part of ANY size, and still never lets a
572
+ // degenerate zoom move the camera further out than it already was.
573
+ // `|| 1` on the zoom for the same reason captureCurrent guards it: a zero
574
+ // would make this non-finite.
575
+ const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
576
+ const offset = from.position.clone().sub(controls.target);
577
+ const distance = Math.min(
578
+ perspectiveDistance({ halfH, zoom: orthoCamera.zoom || 1, fovDeg: camera.fov }),
579
+ Math.max(camera.far * 0.9, offset.length()),
580
+ );
581
+ camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
582
+ }
583
+ to.updateProjectionMatrix();
584
+ activeCamera = to;
585
+ controls.object = to;
586
+ controls.update();
587
+ // The projection matrix is not the world matrix, and `to` has never been
588
+ // rendered — nothing has composed its matrixWorld, which WebGLRenderer would
589
+ // not fix up until the NEXT frame. Two readers get there first: the listener
590
+ // fan-out below is synchronous, and cutaway.updateForCamera runs before
591
+ // render(). Both end up in matrixWorld (raycaster.setFromCamera takes the
592
+ // ray's origin AND direction from it).
593
+ //
594
+ // controls.update() ends in Object3D.lookAt, which does refresh matrixWorld
595
+ // — but it refreshes BEFORE writing the new quaternion, so a rotation
596
+ // applied inside that same update (damping momentum still decaying as the
597
+ // toggle lands) leaves the rotation one frame behind. One matrix compose is
598
+ // cheaper than depending on that ordering. Placed after controls.update()
599
+ // for the same reason: it is the last writer of the pose.
600
+ to.updateMatrixWorld();
601
+ cutaway.setCamera(to);
602
+ projectionMode = next;
603
+ for (const cb of [...projectionListeners]) cb(projectionMode);
604
+ return projectionMode;
605
+ }
606
+
607
+ function onProjectionChange(cb) {
608
+ projectionListeners.add(cb);
609
+ return () => projectionListeners.delete(cb);
610
+ }
611
+
395
612
  // Fallback creasing for payloads with no kernel normals. Both backends now
396
613
  // ship authoritative normals (Manifold: policy-aware crease pass; OCCT:
397
614
  // analytic B-rep normals), so this path is last-ditch only — it must not be
@@ -485,8 +702,11 @@ export function createViewer(container, part) {
485
702
  floorY = -size.z / 2;
486
703
  grid.position.y = floorY;
487
704
  const r = Math.max(size.x, size.y, size.z) || 12;
488
- camera.position.setLength(r * 2.6 + 6);
705
+ activeCamera.position.setLength(r * 2.6 + 6);
489
706
  controls.target.set(0, 0, 0);
707
+ // Framing under ortho is a frustum, not a distance — without this the
708
+ // reframe button moves the camera and nothing visibly changes.
709
+ if (projectionMode === "orthographic") syncOrthoToPerspectiveFraming();
490
710
  }
491
711
 
492
712
  // Show exactly the named sub-parts (from the cache). When `frame` is set, also
@@ -565,6 +785,11 @@ export function createViewer(container, part) {
565
785
  renderer.setSize(w, h);
566
786
  camera.aspect = w / h;
567
787
  camera.updateProjectionMatrix();
788
+ // Hold the ortho camera's VERTICAL extent across a resize and let the width
789
+ // follow the aspect — the same thing the perspective camera does, so a
790
+ // window drag never rescales the part under either projection.
791
+ const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
792
+ applyOrthoFrustum({ halfH, halfW: halfH * (w / h) });
568
793
  lineMaterial.resolution.set(w, h); // fat lines need the viewport size for px width
569
794
  for (const m of fadeLineMats.values()) m.resolution.set(w, h); // clones need it too
570
795
  cutaway.setViewportSize(w, h, renderer.getPixelRatio());
@@ -598,14 +823,21 @@ export function createViewer(container, part) {
598
823
  // no error, live view unaffected, wrong only in the capture.
599
824
  const RT_OPTIONS = { samples: 4, stencilBuffer: true };
600
825
  function renderOffscreen({ position, up, target },
601
- { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9 } = {},
826
+ { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9,
827
+ projection = "perspective", orthoHalfH = 1 } = {},
602
828
  renderScene = scene) {
603
829
  const cachedSize = width === _rtSize && height === _rtSize;
604
830
  const rt = cachedSize
605
831
  ? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
606
832
  : new THREE.WebGLRenderTarget(width, height, RT_OPTIONS);
607
833
  _capLights = _capLights ?? createCaptureLights();
608
- const cam = new THREE.PerspectiveCamera(fov, width / height, 0.1, 1000);
834
+ // Canonical captures never pass `projection`, so agent-facing renders and
835
+ // the CLI stay perspective no matter what the user is looking at.
836
+ const cam = projection === "orthographic"
837
+ ? new THREE.OrthographicCamera(
838
+ -orthoHalfH * (width / height), orthoHalfH * (width / height),
839
+ orthoHalfH, -orthoHalfH, 0.1, 1000)
840
+ : new THREE.PerspectiveCamera(fov, width / height, 0.1, 1000);
609
841
  cam.position.set(position[0], position[1], position[2]);
610
842
  cam.up.set(up[0], up[1], up[2]);
611
843
  cam.lookAt(target[0], target[1], target[2]);
@@ -669,7 +901,9 @@ export function createViewer(container, part) {
669
901
  const radius = Math.max(size.x, size.y, size.z) / 2 || 10;
670
902
  return captureViewsFromScene(viewNames, {
671
903
  renderer: { renderOffscreen },
672
- liveCamera: camera,
904
+ // The live camera only to save/restore its position around the pass; the
905
+ // renders themselves pass no `projection`, so they stay perspective.
906
+ liveCamera: activeCamera,
673
907
  grid,
674
908
  hidden: [...canonicalCaptureHidden],
675
909
  bounds: { center, radius },
@@ -685,10 +919,15 @@ export function createViewer(container, part) {
685
919
  if (!box || box.isEmpty()) return null;
686
920
  return captureCurrentFromScene(opts, {
687
921
  renderer: { renderOffscreen },
688
- liveCamera: camera,
922
+ liveCamera: activeCamera,
689
923
  target: controls.target.toArray(),
690
924
  grid,
691
925
  maxTextureSize: renderer.capabilities?.maxTextureSize,
926
+ projection: projectionMode,
927
+ // Divided by zoom, because OrbitControls dollies an ortho camera with
928
+ // `zoom` and leaves the frustum alone: the raw frustum is the un-dollied
929
+ // framing, so a capture built from it would ignore the user's zoom.
930
+ orthoHalfH: (orthoCamera.top - orthoCamera.bottom) / 2 / (orthoCamera.zoom || 1),
692
931
  });
693
932
  }
694
933
 
@@ -752,8 +991,10 @@ export function createViewer(container, part) {
752
991
  }
753
992
 
754
993
  try {
755
- // fov matches the live camera (and captureViews/captureCurrent) cameraPoseForView's
756
- // distance is tuned to it, so a narrower fov would crop long, thin parts.
994
+ // fov comes from the PERSPECTIVE camera, deliberately, not from whichever
995
+ // camera is live: thumbnails are canonical captures and stay perspective
996
+ // however the user has the projection toggled. cameraPoseForView's distance
997
+ // is tuned to this fov, so a narrower one would crop long, thin parts.
757
998
  return renderOffscreen(pose, { width: size, height: size, fov: camera.fov, quality }, tmpScene);
758
999
  } finally {
759
1000
  for (const mesh of built) {
@@ -778,7 +1019,7 @@ export function createViewer(container, part) {
778
1019
  controls.update();
779
1020
  const tw = camTween.update(dt);
780
1021
  if (tw) {
781
- camera.position.fromArray(tw.position);
1022
+ activeCamera.position.fromArray(tw.position);
782
1023
  controls.target.fromArray(tw.target);
783
1024
  }
784
1025
  // Per-listener guard, because three re-arms requestAnimationFrame only AFTER
@@ -789,8 +1030,8 @@ export function createViewer(container, part) {
789
1030
  try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
790
1031
  }
791
1032
  if (cutaway.isEnabled) cutaway.updateForCamera();
792
- renderer.render(scene, camera);
793
- cutaway.renderOverlay(renderer, camera);
1033
+ renderer.render(scene, activeCamera);
1034
+ cutaway.renderOverlay(renderer, activeCamera);
794
1035
  }
795
1036
  renderer.setAnimationLoop(renderFrame);
796
1037
 
@@ -851,14 +1092,30 @@ export function createViewer(container, part) {
851
1092
  // --- camera state (read/write for persistence; mount.js owns storage) -------
852
1093
  function getCameraState() {
853
1094
  return {
854
- pos: [camera.position.x, camera.position.y, camera.position.z],
1095
+ pos: [activeCamera.position.x, activeCamera.position.y, activeCamera.position.z],
855
1096
  target: [controls.target.x, controls.target.y, controls.target.z],
856
1097
  };
857
1098
  }
858
1099
  function setCameraState({ pos, target }) {
859
- camera.position.set(pos[0], pos[1], pos[2]);
1100
+ activeCamera.position.set(pos[0], pos[1], pos[2]);
860
1101
  controls.target.set(target[0], target[1], target[2]);
861
1102
  controls.update();
1103
+ // A saved pose carries an implied FRAMING, so the ortho frustum has to be
1104
+ // re-derived from the restored distance. Without it, a reload in ortho comes
1105
+ // back at the wrong zoom: the projection is restored during mount setup,
1106
+ // while the camera is restored much later (showView, on the first accepted
1107
+ // build), so the frustum would stay sized for wherever the camera happened
1108
+ // to start. Done here rather than at the mount's call site so every caller
1109
+ // is fixed, including a host that restores a pose itself.
1110
+ //
1111
+ // Deliberately NOT done DURING a tweenCameraTo: an animation camera cue
1112
+ // means "look from this direction", not "reframe". Under ortho the camera's
1113
+ // distance has no effect on apparent size anyway, so re-deriving there would
1114
+ // silently re-zoom the part — and mid-tween, on every frame of one. A caller
1115
+ // that really does mean "refit" opts in with `{ refit: true }`, which runs
1116
+ // this same sync exactly once, on the tween's completion; the view cube's
1117
+ // clicks are the only callers that do.
1118
+ if (projectionMode === "orthographic") syncOrthoToPerspectiveFraming();
862
1119
  }
863
1120
  function onCameraEnd(cb) { controls.addEventListener("end", cb); }
864
1121
 
@@ -897,6 +1154,7 @@ export function createViewer(container, part) {
897
1154
  cameraStartListeners.clear();
898
1155
  frameListeners.clear();
899
1156
  themeListeners.clear();
1157
+ projectionListeners.clear();
900
1158
  canonicalCaptureHidden.clear();
901
1159
  camTween.cancel();
902
1160
  controls.dispose();
@@ -947,6 +1205,7 @@ export function createViewer(container, part) {
947
1205
  onFrame,
948
1206
  tweenCameraTo,
949
1207
  cancelCameraTween,
1208
+ orbitBy,
950
1209
  onCameraStart,
951
1210
  setActive,
952
1211
  onContextLost,
@@ -956,7 +1215,14 @@ export function createViewer(container, part) {
956
1215
  getCameraState,
957
1216
  setCameraState,
958
1217
  onCameraEnd,
959
- camera,
1218
+ // A GETTER, not a value: the active camera changes when the projection is
1219
+ // toggled, and every consumer (measure/dim3-scene.js, selection/raycast.js,
1220
+ // annotate/annotate-mode.js, measure/measure-mode.js) reads viewer.camera
1221
+ // fresh at call time — so this is transparent to all of them.
1222
+ get camera() { return activeCamera; },
1223
+ setProjection,
1224
+ getProjection: () => projectionMode,
1225
+ onProjectionChange,
960
1226
  domElement: renderer.domElement,
961
1227
  _subMeshes: subMesh,
962
1228
  __subMesh: (n) => subMesh[n], // test hooks (cf. attachAnimationControls' __viewer)