partforge 0.107.0 → 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 +1 -1
- package/src/framework/capture-frame.js +37 -8
- package/src/framework/depth-range.js +89 -0
- package/src/framework/viewer.js +105 -21
package/package.json
CHANGED
|
@@ -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,
|
|
33
|
-
: new THREE.PerspectiveCamera(fov, aspect,
|
|
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
|
|
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
|
+
}
|
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
580
|
-
// with no cue as to why.
|
|
581
|
-
//
|
|
582
|
-
//
|
|
583
|
-
//
|
|
584
|
-
//
|
|
585
|
-
//
|
|
586
|
-
//
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|