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.
- package/docs/AUTHORING-PARTS.md +71 -3
- package/package.json +1 -1
- package/src/framework/animation-controls.js +171 -16
- package/src/framework/annotate/annotate-mode.js +31 -3
- package/src/framework/app.css +122 -4
- package/src/framework/camera-orbit.js +84 -0
- package/src/framework/camera-tween.js +22 -10
- package/src/framework/chrome.css +113 -3
- package/src/framework/cutaway-gizmo.js +6 -1
- package/src/framework/cutaway.js +9 -1
- package/src/framework/jobs.js +22 -5
- package/src/framework/measure/dim3-scene.js +15 -2
- package/src/framework/mount.js +79 -2
- package/src/framework/oracle/gates.js +50 -0
- package/src/framework/oracle/measure.js +21 -4
- package/src/framework/oracle/min-wall.js +17 -0
- package/src/framework/oracle/verify.js +63 -22
- package/src/framework/projection.js +19 -0
- package/src/framework/view-angles.js +69 -1
- package/src/framework/view-state.js +9 -0
- package/src/framework/viewcube/cube-canvas.js +410 -0
- package/src/framework/viewcube/cube-geom.js +367 -0
- package/src/framework/viewcube/viewcube-controls.js +157 -0
- package/src/framework/viewcube/viewcube-mode.js +201 -0
- package/src/framework/viewer.js +289 -23
package/src/framework/viewer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
|
756
|
-
//
|
|
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
|
-
|
|
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,
|
|
793
|
-
cutaway.renderOverlay(renderer,
|
|
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: [
|
|
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
|
-
|
|
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)
|