partforge 0.106.1 → 0.108.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.106.1",
3
+ "version": "0.108.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,22 +15,26 @@
15
15
  // recentred image is a pixel-exact crop of what the user framed — at the full
16
16
  // requested resolution and with no second JPEG encode.
17
17
  import * as THREE from "three";
18
-
19
- const NEAR = 0.1;
20
- const FAR = 1000;
18
+ import { DEFAULT_NEAR, DEFAULT_FAR, depthRangeFor } from "./depth-range.js";
21
19
 
22
20
  // The temp camera an offscreen capture renders with. `aspect` is the FULL
23
21
  // frame's aspect; a recentred sub-window is applied afterwards by the caller
24
22
  // via setViewOffset, which (for a PerspectiveCamera) keeps this aspect as the
25
23
  // virtual full frame's. Matrices are updated so a caller can project through
26
24
  // matrixWorldInverse without a render having happened first.
25
+ //
26
+ // `near`/`far` default to the historical fixed pair, which is what a caller
27
+ // with no bounds to offer gets. Derive them from `sceneBounds` instead — see
28
+ // captureDepthRange — and a part large enough to reach 1000 mm keeps its far
29
+ // corner in the picture.
27
30
  export function makeCaptureCamera(
28
31
  { position, up, target },
29
- { aspect = 1, fov = 45, projection = "perspective", orthoHalfH = 1 } = {},
32
+ { aspect = 1, fov = 45, projection = "perspective", orthoHalfH = 1,
33
+ near = DEFAULT_NEAR, far = DEFAULT_FAR } = {},
30
34
  ) {
31
35
  const cam = projection === "orthographic"
32
- ? new THREE.OrthographicCamera(-orthoHalfH * aspect, orthoHalfH * aspect, orthoHalfH, -orthoHalfH, NEAR, FAR)
33
- : new THREE.PerspectiveCamera(fov, aspect, NEAR, FAR);
36
+ ? new THREE.OrthographicCamera(-orthoHalfH * aspect, orthoHalfH * aspect, orthoHalfH, -orthoHalfH, near, far)
37
+ : new THREE.PerspectiveCamera(fov, aspect, near, far);
34
38
  cam.position.set(position[0], position[1], position[2]);
35
39
  cam.up.set(up[0], up[1], up[2]);
36
40
  cam.lookAt(target[0], target[1], target[2]);
@@ -38,6 +42,30 @@ export function makeCaptureCamera(
38
42
  return cam;
39
43
  }
40
44
 
45
+ // The depth range for a capture seen from `pose` that will draw everything
46
+ // inside `sceneBounds` — `{ center, radius }`, where the radius is the one
47
+ // ENCLOSING every visible point, not the framing radius cameraPoseForView
48
+ // takes (that one is half the max extent, which under-reports a box's corners
49
+ // by up to √3 and would clip exactly what this exists to stop clipping).
50
+ //
51
+ // It is one function, called with the
52
+ // same arguments by the render and by the recentring math, because those two
53
+ // must agree to the bit about which vertices are inside the frustum:
54
+ // projectedExtent reports NO extent when any vertex falls outside it, so a
55
+ // recentred capture measured through a different near/far than the render
56
+ // draws with would either give up on a framing that was fine or centre on one
57
+ // that was not. Absent bounds — an empty scene — keeps the fixed pair.
58
+ export function captureDepthRange(pose, { sceneBounds, projection } = {}) {
59
+ if (!sceneBounds) return { near: DEFAULT_NEAR, far: DEFAULT_FAR };
60
+ const [cx, cy, cz] = sceneBounds.center;
61
+ const [px, py, pz] = pose.position;
62
+ return depthRangeFor({
63
+ distance: Math.hypot(px - cx, py - cy, pz - cz),
64
+ radius: sceneBounds.radius,
65
+ projection,
66
+ });
67
+ }
68
+
41
69
  // Exact 2-D extent of the meshes' projected vertices, as fractions of the
42
70
  // frame with a top-left origin ({ left, top, right, bottom }; values outside
43
71
  // [0, 1] mean the geometry runs past that edge). Null when there is nothing
@@ -111,8 +139,9 @@ export function cropRenderFrame(crop, { aspect, long }) {
111
139
  // be kept as-is (part cropped by the viewport, already centred, or nothing to
112
140
  // measure). `meshes` are the visible sub-part meshes; the camera parameters
113
141
  // must be the same ones the render will use.
114
- export function recenteredView(pose, { aspect, fov, projection, orthoHalfH, meshes, long }) {
115
- const camera = makeCaptureCamera(pose, { aspect, fov, projection, orthoHalfH });
142
+ export function recenteredView(pose, { aspect, fov, projection, orthoHalfH, meshes, long, sceneBounds }) {
143
+ const depth = captureDepthRange(pose, { sceneBounds, projection });
144
+ const camera = makeCaptureCamera(pose, { aspect, fov, projection, orthoHalfH, ...depth });
116
145
  const crop = centeredCropView(projectedExtent(camera, meshes));
117
146
  return crop ? cropRenderFrame(crop, { aspect, long }) : null;
118
147
  }
@@ -0,0 +1,89 @@
1
+ // Where the camera's near and far planes go. Pure, so the one property that
2
+ // matters — that nothing the viewer draws can fall outside them — is
3
+ // unit-testable without a renderer.
4
+ //
5
+ // They used to be constants, 0.1 and 1000, from when every part was a spacer
6
+ // or a bracket. `frameTo` frames at `2.6 * maxExtent + 6` mm, so a 300 mm box
7
+ // is viewed from 786 mm with its far corner another 260 mm out: past 1000, and
8
+ // the back of the box is simply not drawn. The first cube that clips is about
9
+ // 287 mm, which is why nothing looked wrong for a year and then arrived as a
10
+ // bug report about one particular box. Zooming out clips it away entirely, and
11
+ // the same constants reached the offscreen captures, so a part big enough took
12
+ // its far corner off the agent-facing renders too.
13
+ //
14
+ // So both planes are derived instead, from the sphere enclosing what is being
15
+ // drawn and the camera's distance to it. Two forces pull against each other:
16
+ //
17
+ // * nothing may be clipped — far must reach past the furthest visible point
18
+ // and near must stop short of the nearest one;
19
+ // * the depth buffer must stay precise — its resolution falls off with the
20
+ // far/near RATIO, so both planes want to hug the geometry.
21
+ //
22
+ // Hugging exactly would rewrite the projection matrix on every frame of every
23
+ // orbit, and a depth buffer whose resolution changes each frame makes
24
+ // near-coplanar surfaces (a part and the feature-edge lines drawn on it)
25
+ // shimmer. So both are QUANTIZED to quarter-octave steps — far rounded up,
26
+ // near rounded down. That keeps each on the safe side of the geometry, bounds
27
+ // the waste at one step, and leaves the projection matrix untouched for as
28
+ // long as a slow dolly stays inside a step.
29
+
30
+ // Quarter-octave steps: neighbouring values differ by 2^(1/4) ≈ 1.19, so the
31
+ // range is at most ~19% wider than it needs to be at each end.
32
+ const STEPS_PER_OCTAVE = 4;
33
+ const quantizeUp = (v) => 2 ** (Math.ceil(Math.log2(v) * STEPS_PER_OCTAVE) / STEPS_PER_OCTAVE);
34
+ const quantizeDown = (v) => 2 ** (Math.floor(Math.log2(v) * STEPS_PER_OCTAVE) / STEPS_PER_OCTAVE);
35
+
36
+ // The scene holds more than the meshes a caller measures its radius from: the
37
+ // cutaway gizmo's handles, measurement dimension lines and their pins, a pick
38
+ // marker. All of them are sized and placed relative to the part, so one
39
+ // proportional margin covers the lot. It costs a slice of depth range and
40
+ // nothing visible.
41
+ export const SCENE_MARGIN = 1.25;
42
+
43
+ // Depth precision is a function of far/near, never of either alone. A 24-bit
44
+ // buffer carries 1e5 comfortably. This ceiling binds only when the camera is
45
+ // inside the scene sphere, which is exactly where there is no true "nearest
46
+ // visible point" to put a near plane in front of.
47
+ const MAX_DEPTH_RATIO = 1e5;
48
+
49
+ // Near plane for a camera inside the sphere, as a fraction of the scene: how
50
+ // close you may put the lens to a surface before it starts to disappear.
51
+ const INSIDE_NEAR_FRACTION = 1e-4;
52
+
53
+ // What an empty scene keeps: the historical constants, so a viewer with
54
+ // nothing shown behaves exactly as it always did.
55
+ export const DEFAULT_NEAR = 0.1;
56
+ export const DEFAULT_FAR = 1000;
57
+
58
+ /**
59
+ * Near/far planes for a camera `distance` from the centre of a sphere of
60
+ * `radius` enclosing everything that will be drawn. Degenerate input (an empty
61
+ * scene, a non-finite pose) falls back to the historical constants rather than
62
+ * producing a broken projection.
63
+ */
64
+ export function depthRangeFor({ distance, radius, projection = "perspective" } = {}) {
65
+ if (!(radius > 0) || !Number.isFinite(distance) || distance < 0) {
66
+ return { near: DEFAULT_NEAR, far: DEFAULT_FAR };
67
+ }
68
+ const reach = radius * SCENE_MARGIN;
69
+ const far = quantizeUp(distance + reach);
70
+ const nearest = distance - reach;
71
+
72
+ // An orthographic projection divides by nothing, so it has no precision
73
+ // falloff and no ratio to defend — and its camera position is a direction
74
+ // more than a place, which is why three allows a NEGATIVE near there. Taking
75
+ // it is what keeps the half of the part "behind" the camera on screen after
76
+ // a swap from a perspective view that had been dollied inside the part.
77
+ if (projection === "orthographic") {
78
+ if (nearest === 0) return { near: 0, far };
79
+ return { near: nearest > 0 ? quantizeDown(nearest) : -quantizeUp(-nearest), far };
80
+ }
81
+
82
+ // A perspective near plane must stay positive, and past this floor the
83
+ // returned near can sit a hair BEYOND the nearest point rather than in front
84
+ // of it. That only happens with the camera within far/1e5 of the sphere's
85
+ // surface — sub-micron on any real part — and the alternative is a depth
86
+ // buffer with no precision left.
87
+ const floor = Math.max(radius * INSIDE_NEAR_FRACTION, far / MAX_DEPTH_RATIO);
88
+ return { near: nearest > 0 ? Math.max(quantizeDown(nearest), floor) : floor, far };
89
+ }
@@ -112,7 +112,12 @@ async function scoreMatchTargets(built, targets, onProgress) {
112
112
  const { best, views } = matchViews(viewMasks, reference, scoreOpts);
113
113
  if (!best) continue; // nothing scoreable — a dropped target, never a zero score
114
114
  const { delta, ...scores } = best;
115
- out.push({ kind: target.kind, best: scores, views, delta: { view: best.view, ...delta } });
115
+ // The caller's own label for this target, echoed verbatim when it sent one.
116
+ // Opaque here: a downstream that sends several photographs needs to know
117
+ // which score is whose, and order cannot say — an unscoreable target is
118
+ // dropped above and everything after it shifts.
119
+ const id = typeof target.id === "string" ? { id: target.id } : {};
120
+ out.push({ kind: target.kind, ...id, best: scores, views, delta: { view: best.view, ...delta } });
116
121
  } catch (err) {
117
122
  onProgress(`match target skipped: ${String(err?.message || err)}`);
118
123
  }
@@ -10,8 +10,9 @@ import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js"
10
10
  import { createCameraTween } from "./camera-tween.js";
11
11
  import { orbitPose } from "./camera-orbit.js";
12
12
  import { orthoFrustum, perspectiveDistance } from "./projection.js";
13
+ import { depthRangeFor } from "./depth-range.js";
13
14
  import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
14
- import { makeCaptureCamera, recenteredView } from "./capture-frame.js";
15
+ import { makeCaptureCamera, recenteredView, captureDepthRange } from "./capture-frame.js";
15
16
  import { CANONICAL_VIEWS, cameraPoseForView } from "./view-angles.js";
16
17
 
17
18
  // three renders into a render target in the LINEAR working colour space: as of r184
@@ -44,7 +45,7 @@ export function srgbEncodeInPlace(data) {
44
45
  // `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
45
46
  // target → readback → JPEG data URL); injected so this is unit-testable without
46
47
  // a GL context. The grid is hidden for the whole synchronous pass and restored.
47
- export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, hidden = [] }) {
48
+ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, sceneBounds, hidden = [] }) {
48
49
  const views = (viewNames?.length ? viewNames : ["iso", "front", "top"])
49
50
  .filter((v) => CANONICAL_VIEWS.includes(v))
50
51
  .slice(0, CANONICAL_VIEWS.length);
@@ -54,10 +55,13 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
54
55
  const hiddenWas = hidden.map((o) => o.visible);
55
56
  for (const o of hidden) o.visible = false;
56
57
  try {
57
- return views.map((view) => ({
58
- view,
59
- dataUrl: renderer.renderOffscreen(cameraPoseForView(view, bounds)),
60
- }));
58
+ return views.map((view) => {
59
+ const pose = cameraPoseForView(view, bounds);
60
+ // `bounds` frames (half the max extent, by cameraPoseForView's contract);
61
+ // `sceneBounds` is the sphere the depth planes must hold. The grid is
62
+ // hidden for this whole pass, so the part alone is in the second one.
63
+ return { view, dataUrl: renderer.renderOffscreen(pose, { sceneBounds }) };
64
+ });
61
65
  } finally {
62
66
  if (grid) grid.visible = gridWasVisible;
63
67
  hidden.forEach((o, i) => { o.visible = hiddenWas[i]; });
@@ -111,7 +115,7 @@ export function thumbnailBackground(background = THUMBNAIL_BG) {
111
115
  // `meshes` are the visible sub-part meshes it reads.
112
116
  export function captureCurrentFromScene(
113
117
  { size = 2048, hideGrid = true, quality = 0.9, recenter = false } = {},
114
- { renderer, liveCamera, target, grid, maxTextureSize, projection = "perspective", orthoHalfH, meshes },
118
+ { renderer, liveCamera, target, grid, maxTextureSize, projection = "perspective", orthoHalfH, meshes, sceneBounds },
115
119
  ) {
116
120
  const MIN_SIZE = 256;
117
121
  // WebGL2 guarantees MAX_TEXTURE_SIZE >= 2048; only trust a larger reported cap.
@@ -133,13 +137,13 @@ export function captureCurrentFromScene(
133
137
  const fov = liveCamera.fov ?? 45;
134
138
  // Null means "keep the viewport framing": part cropped by the viewport,
135
139
  // already centred, or nothing to measure.
136
- const frame = (recenter && recenteredView(pose, { aspect, fov, projection, orthoHalfH, meshes, long }))
140
+ const frame = (recenter && recenteredView(pose, { aspect, fov, projection, orthoHalfH, meshes, long, sceneBounds }))
137
141
  || { width, height };
138
142
  const before = liveCamera.position.clone();
139
143
  const gridWasVisible = grid?.visible;
140
144
  if (grid && hideGrid) grid.visible = false;
141
145
  try {
142
- return renderer.renderOffscreen(pose, { ...frame, fov, quality, projection, orthoHalfH });
146
+ return renderer.renderOffscreen(pose, { ...frame, fov, quality, projection, orthoHalfH, sceneBounds });
143
147
  } finally {
144
148
  if (grid && hideGrid) grid.visible = gridWasVisible;
145
149
  liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
@@ -380,6 +384,56 @@ export function createViewer(container, part) {
380
384
  return _worldBounds;
381
385
  }
382
386
 
387
+ // --- depth range ------------------------------------------------------------
388
+ // The sphere the near/far planes are sized against: everything a render will
389
+ // actually draw, as `{ center, radius }`. `withGrid` is a parameter rather
390
+ // than a read of `grid.visible` because the offscreen captures hide the grid
391
+ // for the duration of their render and ask for their bounds either side of
392
+ // that — and the grid is the bigger half of the answer for a small part
393
+ // (300 mm across a 12 mm spacer), so getting it wrong is not a rounding
394
+ // error. Null when there is nothing to draw.
395
+ const _depthBounds = new THREE.Box3();
396
+ const _depthCenter = new THREE.Vector3();
397
+ const _depthSize = new THREE.Vector3();
398
+ const _gridCorner = new THREE.Vector3();
399
+ function sceneDepthBounds({ withGrid } = {}) {
400
+ // getVisibleWorldBounds returns a SHARED Box3 that the cutaway also reads,
401
+ // so copy before touching it.
402
+ _depthBounds.copy(getVisibleWorldBounds());
403
+ if (withGrid) {
404
+ const half = GRID_SIZE / 2;
405
+ _depthBounds.expandByPoint(_gridCorner.set(-half, floorY, -half));
406
+ _depthBounds.expandByPoint(_gridCorner.set(half, floorY, half));
407
+ }
408
+ if (_depthBounds.isEmpty()) return null;
409
+ return {
410
+ center: _depthBounds.getCenter(_depthCenter).toArray(),
411
+ radius: _depthBounds.getSize(_depthSize).length() / 2,
412
+ };
413
+ }
414
+
415
+ // Re-size the live camera's depth range to the scene, once per frame. Cheap
416
+ // by construction — the bounds are a union of already-computed per-mesh
417
+ // boxes, and depthRangeFor quantizes, so the projection matrix is rebuilt
418
+ // only when the answer actually moves a step. Only the ACTIVE camera is
419
+ // written: the two projections take different near planes (an orthographic
420
+ // one may legitimately be negative, which would be a broken perspective
421
+ // matrix), and setProjection re-runs this on the camera it swaps in.
422
+ function updateDepthRange() {
423
+ const bounds = sceneDepthBounds({ withGrid: grid.visible });
424
+ if (!bounds) return; // nothing shown — leave the planes where they are
425
+ const { near, far } = depthRangeFor({
426
+ // _depthCenter is the vector sceneDepthBounds just wrote its centre into.
427
+ distance: activeCamera.position.distanceTo(_depthCenter),
428
+ radius: bounds.radius,
429
+ projection: projectionMode,
430
+ });
431
+ if (activeCamera.near === near && activeCamera.far === far) return;
432
+ activeCamera.near = near;
433
+ activeCamera.far = far;
434
+ activeCamera.updateProjectionMatrix();
435
+ }
436
+
383
437
  const cutaway = createCutaway({
384
438
  renderer,
385
439
  scene,
@@ -576,21 +630,24 @@ export function createViewer(container, part) {
576
630
  // The bound exists because ortho zoom is UNBOUNDED and zooming a long way
577
631
  // out costs nothing there (an ortho projection has no depth falloff) —
578
632
  // while the recovered distance goes as 1/zoom, so a zoom near nothing would
579
- // fling the perspective camera past its own far plane and blank the viewer
580
- // with no cue as to why. `far * 0.9` alone would be too eager: frameTo
581
- // frames at 2.6r + 6 MILLIMETRES, so an everyday 300mm part sits at 786mm
582
- // and a plain toggle would silently reframe it closer. Hence the max with
583
- // the distance the camera is already at, which makes an untouched round
584
- // trip (zoom === 1, where orthoFrustum/perspectiveDistance are exact
585
- // inverses) lossless for a part of ANY size, and still never lets a
586
- // degenerate zoom move the camera further out than it already was.
633
+ // fling the perspective camera an absurd distance out and leave the part a
634
+ // speck, with no cue as to why. The far plane is the yardstick because it
635
+ // is by definition past everything worth looking at; read off `from`,
636
+ // which is the camera that is still live and therefore the one
637
+ // updateDepthRange has been keeping current. `far * 0.9` alone would be
638
+ // too eager: frameTo frames at 2.6r + 6 MILLIMETRES, so an everyday 300mm
639
+ // part sits at 786mm and a plain toggle would silently reframe it closer.
640
+ // Hence the max with the distance the camera is already at, which makes an
641
+ // untouched round trip (zoom === 1, where orthoFrustum/perspectiveDistance
642
+ // are exact inverses) lossless for a part of ANY size, and still never lets
643
+ // a degenerate zoom move the camera further out than it already was.
587
644
  // `|| 1` on the zoom for the same reason captureCurrent guards it: a zero
588
645
  // would make this non-finite.
589
646
  const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
590
647
  const offset = from.position.clone().sub(controls.target);
591
648
  const distance = Math.min(
592
649
  perspectiveDistance({ halfH, zoom: orthoCamera.zoom || 1, fovDeg: camera.fov }),
593
- Math.max(camera.far * 0.9, offset.length()),
650
+ Math.max(from.far * 0.9, offset.length()),
594
651
  );
595
652
  camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
596
653
  }
@@ -598,6 +655,11 @@ export function createViewer(container, part) {
598
655
  activeCamera = to;
599
656
  controls.object = to;
600
657
  controls.update();
658
+ // The incoming camera's depth range is whatever it was left with when it
659
+ // was last live, and the two projections do not take the same near plane.
660
+ // Everything below reads a projection matrix, so re-derive it here rather
661
+ // than waiting for the next frame.
662
+ updateDepthRange();
601
663
  // The projection matrix is not the world matrix, and `to` has never been
602
664
  // rendered — nothing has composed its matrixWorld, which WebGLRenderer would
603
665
  // not fix up until the NEXT frame. Two readers get there first: the listener
@@ -854,7 +916,7 @@ export function createViewer(container, part) {
854
916
  // output is a pixel-exact crop of what the user framed.
855
917
  function renderOffscreen(pose,
856
918
  { width = _rtSize, height = _rtSize, fov = 45, quality = 0.9,
857
- projection = "perspective", orthoHalfH = 1, viewOffset } = {},
919
+ projection = "perspective", orthoHalfH = 1, viewOffset, sceneBounds } = {},
858
920
  renderScene = scene) {
859
921
  const cachedSize = width === _rtSize && height === _rtSize;
860
922
  const rt = cachedSize
@@ -866,7 +928,12 @@ export function createViewer(container, part) {
866
928
  // through the same helper the recentring math projects through, so the two
867
929
  // can never disagree about where a vertex lands.
868
930
  const aspect = viewOffset ? viewOffset.fullWidth / viewOffset.fullHeight : width / height;
869
- const cam = makeCaptureCamera(pose, { aspect, fov, projection, orthoHalfH });
931
+ // `sceneBounds` encloses what this render will draw; without it the camera
932
+ // keeps the fixed historical planes, which is right for a caller with
933
+ // nothing to measure and wrong for a part 300 mm across.
934
+ const cam = makeCaptureCamera(pose, {
935
+ aspect, fov, projection, orthoHalfH, ...captureDepthRange(pose, { sceneBounds, projection }),
936
+ });
870
937
  if (viewOffset) cam.setViewOffset(viewOffset.fullWidth, viewOffset.fullHeight, viewOffset.x, viewOffset.y, width, height);
871
938
  const { position, up, target } = pose;
872
939
  const buf = new Uint8Array(width * height * 4);
@@ -948,6 +1015,10 @@ export function createViewer(container, part) {
948
1015
  grid,
949
1016
  hidden: [...canonicalCaptureHidden],
950
1017
  bounds: { center, radius },
1018
+ // The ENCLOSING radius, which is a different number from the framing one
1019
+ // above: a box's corners reach √3 further than half its max extent, and
1020
+ // the depth planes have to clear the corners.
1021
+ sceneBounds: { center, radius: size.length() / 2 || 10 },
951
1022
  });
952
1023
  }
953
1024
 
@@ -967,6 +1038,9 @@ export function createViewer(container, part) {
967
1038
  // For `recenter`: the geometry that is actually in the picture. Sub-part
968
1039
  // meshes only — dimension labels and section caps are overlays on them.
969
1040
  meshes: Object.values(subMesh).filter((m) => m.visible),
1041
+ // For the depth planes. `hideGrid` is the capture's own default-on option,
1042
+ // so ask whether the grid will still be there when the render happens.
1043
+ sceneBounds: sceneDepthBounds({ withGrid: grid.visible && opts?.hideGrid === false }),
970
1044
  projection: projectionMode,
971
1045
  // Divided by zoom, because OrbitControls dollies an ortho camera with
972
1046
  // `zoom` and leaves the frustum alone: the raw frustum is the un-dollied
@@ -1039,7 +1113,13 @@ export function createViewer(container, part) {
1039
1113
  // camera is live: thumbnails are canonical captures and stay perspective
1040
1114
  // however the user has the projection toggled. cameraPoseForView's distance
1041
1115
  // is tuned to this fov, so a narrower one would crop long, thin parts.
1042
- return renderOffscreen(pose, { width: size, height: size, fov: camera.fov, quality }, tmpScene);
1116
+ return renderOffscreen(
1117
+ pose,
1118
+ // The throwaway scene holds these meshes and nothing else — no grid, no
1119
+ // gizmo — so its own bounds are the whole of what the planes must hold.
1120
+ { width: size, height: size, fov: camera.fov, quality, sceneBounds: { center, radius } },
1121
+ tmpScene,
1122
+ );
1043
1123
  } finally {
1044
1124
  for (const mesh of built) {
1045
1125
  mesh.geometry.userData.edges?.dispose();
@@ -1073,6 +1153,10 @@ export function createViewer(container, part) {
1073
1153
  for (const cb of [...frameListeners]) {
1074
1154
  try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
1075
1155
  }
1156
+ // After the frame listeners, before anything reads the camera to draw with:
1157
+ // a playback frame may have moved sub-parts or the camera itself, and both
1158
+ // change where the planes belong.
1159
+ updateDepthRange();
1076
1160
  if (cutaway.isEnabled) cutaway.updateForCamera();
1077
1161
  // Re-size the pick markers against the pose this frame will actually draw:
1078
1162
  // a dot is only alive for about a second, but orbiting or zooming inside