partforge 0.123.0 → 0.125.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/README.md +3 -2
- package/docs/AUTHORING-PARTS.md +72 -36
- package/package.json +1 -1
- package/src/framework/annotate/annotate-mode.js +6 -1
- package/src/framework/app.css +69 -57
- package/src/framework/chrome.css +38 -25
- package/src/framework/cutaway-gizmo.js +2 -1
- package/src/framework/cutaway.js +33 -0
- package/src/framework/geometry/threemf.js +3 -2
- package/src/framework/lint/rules-materials.js +3 -3
- package/src/framework/materials/assets/SOURCES.md +1 -1
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/contact-shadow.js +28 -1
- package/src/framework/materials/environment.js +16 -4
- package/src/framework/materials/patterns.js +58 -7
- package/src/framework/materials/physical.js +1 -0
- package/src/framework/materials/presets.js +9 -9
- package/src/framework/materials/print-bed.js +17 -0
- package/src/framework/materials/resolve.js +38 -14
- package/src/framework/mount.js +111 -44
- package/src/framework/projection.js +48 -1
- package/src/framework/view-state.js +4 -10
- package/src/framework/view-style-controls.js +298 -0
- package/src/framework/view-style-state.js +27 -0
- package/src/framework/viewcube/viewcube-controls.js +23 -64
- package/src/framework/viewcube/viewcube-mode.js +5 -3
- package/src/framework/viewer.js +637 -51
- package/src/parts/material-swatches.js +8 -1
- package/types/index.d.ts +14 -8
- package/src/framework/realistic-controls.js +0 -78
package/src/framework/viewer.js
CHANGED
|
@@ -6,6 +6,7 @@ import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js
|
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
7
|
import { UltraHDRLoader } from "three/addons/loaders/UltraHDRLoader.js";
|
|
8
8
|
import { buildCadMaterial, buildPhysicalMaterial } from "./materials/physical.js";
|
|
9
|
+
import { grainAxisFor, setGrainAxis } from "./materials/patterns.js";
|
|
9
10
|
import { ensureBoxUVs } from "./materials/uv.js";
|
|
10
11
|
import { loadEnvironmentRig } from "./materials/environment.js";
|
|
11
12
|
import { assetUrl } from "./materials/assets.js";
|
|
@@ -16,7 +17,7 @@ import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
|
16
17
|
import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
|
|
17
18
|
import { createCameraTween } from "./camera-tween.js";
|
|
18
19
|
import { orbitPose } from "./camera-orbit.js";
|
|
19
|
-
import { orthoFrustum, perspectiveDistance } from "./projection.js";
|
|
20
|
+
import { orthoFrustum, perspectiveDistance, isFaceAligned, FACE_ALIGN_COS } from "./projection.js";
|
|
20
21
|
import { depthRangeFor } from "./depth-range.js";
|
|
21
22
|
import { addViewerLights, captureLightPoses, createCaptureLights, createHemisphereLight } from "./viewer-lighting.js";
|
|
22
23
|
import { makeCaptureCamera, recenteredView, captureDepthRange } from "./capture-frame.js";
|
|
@@ -154,7 +155,7 @@ export function captureCurrentFromScene(
|
|
|
154
155
|
const long = Math.min(Math.max(Math.round(size) || MIN_SIZE, MIN_SIZE), maxTextureSize ?? 2048);
|
|
155
156
|
// An OrthographicCamera has no `aspect` — its aspect lives in the frustum. Read
|
|
156
157
|
// it there, or the capture comes back SQUARE from a wide viewport the moment the
|
|
157
|
-
//
|
|
158
|
+
// view goes ortho: silent, and only wrong in the saved image.
|
|
158
159
|
const aspect = liveCamera.aspect
|
|
159
160
|
|| (liveCamera.isOrthographicCamera
|
|
160
161
|
? (liveCamera.right - liveCamera.left) / (liveCamera.top - liveCamera.bottom)
|
|
@@ -210,7 +211,7 @@ export function createViewer(container, part) {
|
|
|
210
211
|
|
|
211
212
|
// Two cameras, one active. The perspective camera stays the source of truth
|
|
212
213
|
// for fov and aspect; the ortho camera borrows both through projection.js so
|
|
213
|
-
// a
|
|
214
|
+
// a swap never changes the part's size on screen.
|
|
214
215
|
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 1000);
|
|
215
216
|
camera.position.set(18, 12, 18);
|
|
216
217
|
const orthoCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 1000);
|
|
@@ -218,6 +219,15 @@ export function createViewer(container, part) {
|
|
|
218
219
|
let activeCamera = camera;
|
|
219
220
|
let projectionMode = "perspective";
|
|
220
221
|
const projectionListeners = new Set();
|
|
222
|
+
// The view direction (target -> camera) orthographic was entered on. The
|
|
223
|
+
// projection is AUTOMATIC (Fusion 360's "Perspective with Ortho Faces"): a
|
|
224
|
+
// view cube face click settles into ortho, and the first frame the direction
|
|
225
|
+
// has turned off this axis — a rotation, never a pan or zoom — swaps back to
|
|
226
|
+
// perspective. Non-null exactly while ortho is live. See checkAutoProjection.
|
|
227
|
+
let orthoAxis = null;
|
|
228
|
+
// Bumped by every change to what a ray down the view axis could hit — see
|
|
229
|
+
// sizeMatchDepth. Declared up here because the geometry setters bump it.
|
|
230
|
+
let matchGeneration = 0;
|
|
221
231
|
|
|
222
232
|
const controls = new OrbitControls(activeCamera, renderer.domElement);
|
|
223
233
|
controls.enableDamping = true;
|
|
@@ -294,13 +304,35 @@ export function createViewer(container, part) {
|
|
|
294
304
|
const fadeLineMats = new Map(); // name -> lazily cloned LineMaterial
|
|
295
305
|
const fadeUnregisters = new Map(); // fade material -> its cutaway unregister fn
|
|
296
306
|
let lastShown = []; // names last passed to showAssembly
|
|
297
|
-
// The render mode's LOOK, read by applySubOpacity (feature lines are
|
|
298
|
-
// and the depth range (the realistic ground disc). Declared
|
|
299
|
-
// everything that reads it; the mode itself lives in the
|
|
307
|
+
// The render mode's LOOK, read by applySubOpacity (feature lines are drawn
|
|
308
|
+
// only in "cad") and the depth range (the realistic ground disc). Declared
|
|
309
|
+
// here, ahead of everything that reads it; the mode itself lives in the
|
|
310
|
+
// realistic-mode block.
|
|
300
311
|
let renderMode = "cad";
|
|
301
312
|
let realisticRig = null;
|
|
302
313
|
let shadowMovedAt = null; // performance.now() of a pose change the contact shadow has not caught up with
|
|
303
314
|
|
|
315
|
+
// three calls scene.onBeforeRender(renderer, scene, camera, target) at the
|
|
316
|
+
// very start of WebGLRenderer.render — after scene/camera matrixWorld are
|
|
317
|
+
// current, before objects are projected — so visibility set here applies to
|
|
318
|
+
// THAT render with the camera actually doing it: the live canvas, an
|
|
319
|
+
// offscreen capture (renderOffscreen reuses this same `scene`), and the
|
|
320
|
+
// contact shadow's own depth pass (which renders `scene` from below with
|
|
321
|
+
// its own camera, hiding every non-caster and restoring them in `finally`).
|
|
322
|
+
// That restore is not exact for the print bed: if the bed group was already
|
|
323
|
+
// hidden going in, the hook can set it visible for the shadow camera and
|
|
324
|
+
// leave it that way after the pass. Harmless — the next render's hook
|
|
325
|
+
// recomputes the flag for ITS camera before anything is drawn, and the
|
|
326
|
+
// plate lies outside the shadow camera's frustum, so the depth pass never
|
|
327
|
+
// draws it either way. No-op outside realistic mode or for a rig
|
|
328
|
+
// whose environment has nothing to hide (the ground discs already cull by
|
|
329
|
+
// their material's own side).
|
|
330
|
+
const priorSceneOnBeforeRender = scene.onBeforeRender;
|
|
331
|
+
scene.onBeforeRender = (r, s, camera, target) => {
|
|
332
|
+
priorSceneOnBeforeRender?.(r, s, camera, target);
|
|
333
|
+
if (renderMode === "realistic") realisticRig?.updateForCamera?.(camera);
|
|
334
|
+
};
|
|
335
|
+
|
|
304
336
|
const effectiveVisible = () => lastShown.filter((n) => (animOpacity.get(n) ?? 1) > 0);
|
|
305
337
|
|
|
306
338
|
// A fade clone is a material the cutaway does not own, so it has to be told
|
|
@@ -388,6 +420,9 @@ export function createViewer(container, part) {
|
|
|
388
420
|
applySubOpacity(name);
|
|
389
421
|
const isZero = (animOpacity.get(name) ?? 1) <= 0;
|
|
390
422
|
if (wasZero !== isZero) {
|
|
423
|
+
// A part faded to (or back from) nothing is a visibility change like
|
|
424
|
+
// showAssembly's, so the remembered size-match surface asks afresh.
|
|
425
|
+
matchGeneration++;
|
|
391
426
|
cutaway.setVisible(effectiveVisible());
|
|
392
427
|
if (realisticRig) shadowMovedAt = performance.now(); // a caster came or went
|
|
393
428
|
}
|
|
@@ -398,6 +433,7 @@ export function createViewer(container, part) {
|
|
|
398
433
|
const touched = [...animOpacity.keys()];
|
|
399
434
|
animOpacity.clear();
|
|
400
435
|
for (const n of touched) applySubOpacity(n);
|
|
436
|
+
matchGeneration++; // any part that was faded out is visible again
|
|
401
437
|
cutaway.setVisible(effectiveVisible());
|
|
402
438
|
}
|
|
403
439
|
|
|
@@ -512,13 +548,22 @@ export function createViewer(container, part) {
|
|
|
512
548
|
let modeToken = 0; // bumped by every request; a stale completion does nothing
|
|
513
549
|
let realisticPending = false; // a realistic request is in flight (setEnvironment joins it)
|
|
514
550
|
let environmentChosen = false; // set by setEnvironment; false while seeded from meta/default
|
|
551
|
+
|
|
552
|
+
// Feature lines are CAD-only: applySubOpacity reads the render mode
|
|
553
|
+
// directly, so this just re-applies it to every sub-part after a mode
|
|
554
|
+
// change (enterRealistic/enterCad both call it once they've set renderMode).
|
|
555
|
+
function refreshFeatureLines() { for (const n of names) applySubOpacity(n); }
|
|
556
|
+
|
|
515
557
|
const physicalMats = new Map(); // name -> MeshPhysicalMaterial (environment-independent, cached)
|
|
516
558
|
const rigCache = new Map(); // environment id -> Promise<Rig>
|
|
517
559
|
const loadedRigs = new Map(); // environment id -> Rig, once its load has landed (synchronous captures)
|
|
560
|
+
const borrowedRigRefs = new Map(); // environment id -> count of in-flight captures (thumbnails, renderViews) borrowing it
|
|
518
561
|
const textureCache = new Map(); // asset file name -> Texture
|
|
519
562
|
const textureLoader = new THREE.TextureLoader();
|
|
520
563
|
let pmrem = null;
|
|
521
564
|
let printFrames = {};
|
|
565
|
+
let printFrameSource = null; // () => frames; mount's lazy provider (setPrintFrameSource)
|
|
566
|
+
let printFramesStale = false; // the source has frames the materials have not seen
|
|
522
567
|
const modeListeners = new Set();
|
|
523
568
|
const envListeners = new Set();
|
|
524
569
|
const SHADOW_LOWRES_MS = 100; // at most one low-res shadow per this, while a part moves
|
|
@@ -530,15 +575,44 @@ export function createViewer(container, part) {
|
|
|
530
575
|
|
|
531
576
|
// Anisotropic filtering: the ground disc and the wood grain are seen at grazing
|
|
532
577
|
// angles, where plain trilinear mip selection blurs them into smeared blocks.
|
|
578
|
+
//
|
|
579
|
+
// TextureLoader.load returns at once and fills `image` later, so a rig (or a
|
|
580
|
+
// physical material) is "ready" long before its maps are: a capture taken
|
|
581
|
+
// straight away draws the floor and the wood untextured. Each load therefore
|
|
582
|
+
// records a settle promise (resolved on load AND on error — a missing map is
|
|
583
|
+
// drawn without it, never an error), and whenTexturesSettled() waits for them.
|
|
584
|
+
const pendingTextures = new Set();
|
|
533
585
|
const loadTexture = (file) => {
|
|
534
586
|
let t = textureCache.get(file);
|
|
535
587
|
if (!t) {
|
|
536
|
-
|
|
588
|
+
let settle;
|
|
589
|
+
const settled = new Promise((resolve) => { settle = resolve; });
|
|
590
|
+
pendingTextures.add(settled);
|
|
591
|
+
settled.then(() => pendingTextures.delete(settled));
|
|
592
|
+
t = textureLoader.load(assetUrl(file), () => settle(), undefined, () => settle());
|
|
537
593
|
t.anisotropy = Math.min(8, renderer.capabilities?.getMaxAnisotropy?.() ?? 1);
|
|
538
594
|
textureCache.set(file, t);
|
|
539
595
|
}
|
|
540
596
|
return t;
|
|
541
597
|
};
|
|
598
|
+
// Every texture requested so far has loaded or failed. Loops, because waiting
|
|
599
|
+
// can overlap more requests (a material built by a later compile). Bounded:
|
|
600
|
+
// an image request that never answers must not hang a mode switch or a
|
|
601
|
+
// capture forever — past the cap it draws with whatever has arrived.
|
|
602
|
+
const TEXTURE_SETTLE_CAP_MS = 15000;
|
|
603
|
+
async function whenTexturesSettled() {
|
|
604
|
+
const deadline = Date.now() + TEXTURE_SETTLE_CAP_MS;
|
|
605
|
+
while (pendingTextures.size) {
|
|
606
|
+
const left = deadline - Date.now();
|
|
607
|
+
if (left <= 0) return;
|
|
608
|
+
let timer;
|
|
609
|
+
await Promise.race([
|
|
610
|
+
Promise.all([...pendingTextures]),
|
|
611
|
+
new Promise((resolve) => { timer = setTimeout(resolve, left); }),
|
|
612
|
+
]);
|
|
613
|
+
clearTimeout(timer);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
542
616
|
const loadHdr = (url) => new UltraHDRLoader().setDataType(THREE.HalfFloatType).loadAsync(url);
|
|
543
617
|
function rigFor(id) {
|
|
544
618
|
if (!rigCache.has(id)) {
|
|
@@ -552,6 +626,52 @@ export function createViewer(container, part) {
|
|
|
552
626
|
return rigCache.get(id);
|
|
553
627
|
}
|
|
554
628
|
|
|
629
|
+
// A rig loaded only to draw a capture (a thumbnail, or renderViews from CAD) is freed straight away: each holds
|
|
630
|
+
// its equirect (~16 MB of half-float) and a PMREM, and four of them resident
|
|
631
|
+
// on a phone is not acceptable. The live one (or one a switch is loading) stays
|
|
632
|
+
// — and only those: in CAD, the default environment is freed like any other.
|
|
633
|
+
// rig.dispose() also disposes the ground textures loadTexture cached — safe:
|
|
634
|
+
// each environment.js ground.texture/roughnessTexture/normalTexture is a
|
|
635
|
+
// distinct file (environments.js), so no two rigs ever share a Texture
|
|
636
|
+
// object, and Texture.dispose() only frees the GPU handle (via the
|
|
637
|
+
// renderer's 'dispose' listener) — it leaves texture.image and every field
|
|
638
|
+
// three's uploader reads untouched. loadTexture keeps returning that same
|
|
639
|
+
// (still-intact) Texture object out of textureCache, so a later rigFor for
|
|
640
|
+
// this id just re-uploads it on the next render, exactly like a first load.
|
|
641
|
+
//
|
|
642
|
+
// Concurrent captures of the same environment share one in-flight rig
|
|
643
|
+
// promise (rigFor's own cache), and a rig's envMap is a PMREM render-target
|
|
644
|
+
// texture that CANNOT re-upload once disposed — so releasing while a
|
|
645
|
+
// sibling call is still capturing with it would leave that capture's
|
|
646
|
+
// reflections black. EVERY async capture that awaits a rig must therefore
|
|
647
|
+
// borrow it through borrowRig and give it back through releaseRig in a
|
|
648
|
+
// `finally` (renderStyleThumbnail and renderViews both do). `p` is the
|
|
649
|
+
// exact promise borrowRig took from rigFor: `borrowedRigRefs` counts
|
|
650
|
+
// in-flight users of `id` (bumped synchronously in borrowRig, BEFORE any
|
|
651
|
+
// await, so an overlapping borrower is always already counted; decremented
|
|
652
|
+
// here), and this only deletes/
|
|
653
|
+
// disposes once that count reaches 0 AND `rigCache.get(id) === p` — the
|
|
654
|
+
// second check is what stops a call from freeing a *different* rig a later
|
|
655
|
+
// load (or the live environment) has since put in the cache for the same id.
|
|
656
|
+
function borrowRig(id) {
|
|
657
|
+
const p = rigFor(id);
|
|
658
|
+
borrowedRigRefs.set(id, (borrowedRigRefs.get(id) ?? 0) + 1);
|
|
659
|
+
return p;
|
|
660
|
+
}
|
|
661
|
+
function releaseRig(id, p) {
|
|
662
|
+
const refs = (borrowedRigRefs.get(id) ?? 0) - 1;
|
|
663
|
+
if (refs > 0) { borrowedRigRefs.set(id, refs); return; }
|
|
664
|
+
borrowedRigRefs.delete(id);
|
|
665
|
+
// Keep only a rig that is (or is about to be) on screen. In CAD the
|
|
666
|
+
// current environment's id alone is no reason: nothing is showing it, and
|
|
667
|
+
// a realistic switch reloads it (from the texture cache) when asked.
|
|
668
|
+
if (realisticRig?.id === id || (realisticPending && id === environmentId)) return;
|
|
669
|
+
if (rigCache.get(id) !== p) return;
|
|
670
|
+
rigCache.delete(id);
|
|
671
|
+
loadedRigs.delete(id);
|
|
672
|
+
p.then((r) => r.dispose(), () => {});
|
|
673
|
+
}
|
|
674
|
+
|
|
555
675
|
// three's Material.copy carries neither onBeforeCompile nor
|
|
556
676
|
// customProgramCacheKey, and it JSON-copies userData. For a patterned
|
|
557
677
|
// physical material (patterns.js) that means every clone the cutaway or a
|
|
@@ -580,9 +700,19 @@ export function createViewer(container, part) {
|
|
|
580
700
|
if (!m) {
|
|
581
701
|
m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
|
|
582
702
|
physicalMats.set(name, m);
|
|
703
|
+
syncGrain(name);
|
|
583
704
|
}
|
|
584
705
|
return m;
|
|
585
706
|
}
|
|
707
|
+
// Wood grain runs along the sub-part's longest axis (patterns.js). The
|
|
708
|
+
// uniforms are shared with every clone, so setting them once reaches the
|
|
709
|
+
// cutaway's and the fades' copies too. Called when the material is built and
|
|
710
|
+
// whenever new geometry lands (a regen can change which axis is longest).
|
|
711
|
+
function syncGrain(name) {
|
|
712
|
+
const m = physicalMats.get(name);
|
|
713
|
+
const geo = subCache[name];
|
|
714
|
+
if (m && geo) setGrainAxis(m, grainAxisFor(geo.boundingBox));
|
|
715
|
+
}
|
|
586
716
|
|
|
587
717
|
function publishMode(extra = {}) {
|
|
588
718
|
const evt = { mode: renderMode, busy: false, error: null, ...extra };
|
|
@@ -622,11 +752,14 @@ export function createViewer(container, part) {
|
|
|
622
752
|
.map((n) => subMesh[n]);
|
|
623
753
|
// `force` renders even while parked: a capture is offscreen work a parked
|
|
624
754
|
// viewer still does, and it must not bake a stale (or absent) shadow.
|
|
755
|
+
// The cutaway's plane clips the shadow's casters too, so the half it cut
|
|
756
|
+
// away casts no shadow (cutaway.onChange below re-renders it as it moves).
|
|
757
|
+
const _shadowClipPlane = new THREE.Plane();
|
|
625
758
|
function renderShadow(opts, { force = false } = {}) {
|
|
626
759
|
if (!realisticRig || (!active && !force)) return;
|
|
627
760
|
try {
|
|
628
|
-
|
|
629
|
-
|
|
761
|
+
const plane = cutaway.getPlane?.(_shadowClipPlane) ?? null;
|
|
762
|
+
realisticRig.shadow.render(scene, castersNow(), { ...opts, clippingPlanes: plane ? [plane] : null });
|
|
630
763
|
} catch (e) {
|
|
631
764
|
console.warn("partforge: contact shadow failed", e);
|
|
632
765
|
}
|
|
@@ -659,6 +792,10 @@ export function createViewer(container, part) {
|
|
|
659
792
|
renderShadow({ lowRes: true });
|
|
660
793
|
}
|
|
661
794
|
}
|
|
795
|
+
// A cutaway turned on or off, or its plane moved, changes what casts the
|
|
796
|
+
// contact shadow: low-res while the plane is dragged, full once it settles —
|
|
797
|
+
// the same schedule as a moving sub-part.
|
|
798
|
+
cutaway.onChange?.(() => { if (realisticRig) shadowMovedAt = performance.now(); });
|
|
662
799
|
|
|
663
800
|
const livePixelRatio = (mode) =>
|
|
664
801
|
Math.min(devicePixelRatio, mode === "realistic" && isCoarsePointer() ? 1.5 : 2);
|
|
@@ -677,12 +814,13 @@ export function createViewer(container, part) {
|
|
|
677
814
|
// ground only moves on showAssembly, never because a capture happened.
|
|
678
815
|
function enterRealistic(rig, { live = true, reground = true } = {}) {
|
|
679
816
|
try {
|
|
817
|
+
syncPrintFrames(); // before the swap: the first realistic frame has the export-pose layers
|
|
680
818
|
renderMode = "realistic";
|
|
681
819
|
// A regen can land between the proxy compile and here, while the mode
|
|
682
820
|
// was still CAD, so setSubGeometry skipped the UVs. Idempotent.
|
|
683
821
|
for (const n of names) if (subCache[n] && physicalFor(n).userData.pfAnisotropic) ensureBoxUVs(subCache[n]);
|
|
684
822
|
setSubMaterials(physicalFor);
|
|
685
|
-
if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
|
|
823
|
+
if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group, ...(realisticRig.lights ?? []));
|
|
686
824
|
realisticRig = rig;
|
|
687
825
|
scene.environment = rig.envMap;
|
|
688
826
|
scene.background = rig.background;
|
|
@@ -690,13 +828,15 @@ export function createViewer(container, part) {
|
|
|
690
828
|
scene.backgroundIntensity = rig.backgroundIntensity ?? 1;
|
|
691
829
|
scene.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
|
|
692
830
|
scene.environmentRotation.set(0, rig.rotationY ?? 0, 0);
|
|
693
|
-
scene.add(rig.ground, rig.shadow.group);
|
|
831
|
+
scene.add(rig.ground, rig.shadow.group, ...(rig.lights ?? []));
|
|
694
832
|
for (const l of Object.values(liveLights)) l.visible = false;
|
|
695
833
|
grid.visible = false;
|
|
696
834
|
renderer.toneMapping = THREE.NeutralToneMapping;
|
|
697
835
|
renderer.toneMappingExposure = rig.exposure;
|
|
698
836
|
if (reground) placeGround({ force: !live });
|
|
699
837
|
if (live) applyPixelRatio("realistic");
|
|
838
|
+
// Hides the lines on every sub-part now that renderMode reads realistic.
|
|
839
|
+
refreshFeatureLines();
|
|
700
840
|
} catch (e) {
|
|
701
841
|
try { enterCad({ live }); } catch (rollback) { console.warn("partforge: rolling back to CAD failed", rollback); }
|
|
702
842
|
throw e;
|
|
@@ -714,7 +854,7 @@ export function createViewer(container, part) {
|
|
|
714
854
|
});
|
|
715
855
|
renderMode = "cad";
|
|
716
856
|
attempt(() => {
|
|
717
|
-
if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
|
|
857
|
+
if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group, ...(realisticRig.lights ?? []));
|
|
718
858
|
});
|
|
719
859
|
realisticRig = null;
|
|
720
860
|
shadowMovedAt = null;
|
|
@@ -730,6 +870,7 @@ export function createViewer(container, part) {
|
|
|
730
870
|
attempt(() => { grid.visible = true; });
|
|
731
871
|
for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
|
|
732
872
|
if (live) attempt(() => applyPixelRatio("cad"));
|
|
873
|
+
attempt(refreshFeatureLines);
|
|
733
874
|
if (firstError) throw firstError;
|
|
734
875
|
}
|
|
735
876
|
|
|
@@ -743,6 +884,7 @@ export function createViewer(container, part) {
|
|
|
743
884
|
// target bound — each only for the synchronous compile() inside
|
|
744
885
|
// compileAsync, so the view on screen is untouched while it waits.
|
|
745
886
|
function compileRealistic(rig, { forCapture = false } = {}) {
|
|
887
|
+
syncPrintFrames();
|
|
746
888
|
const proxy = new THREE.Scene();
|
|
747
889
|
proxy.environment = rig.envMap;
|
|
748
890
|
for (const n of names) {
|
|
@@ -795,6 +937,10 @@ export function createViewer(container, part) {
|
|
|
795
937
|
if (isStale(token)) return renderMode;
|
|
796
938
|
await compileRealistic(rig);
|
|
797
939
|
if (isStale(token)) return renderMode;
|
|
940
|
+
// The ground's and materials' maps: without this the floor pops in a
|
|
941
|
+
// frame or two after the switch, untextured first.
|
|
942
|
+
await whenTexturesSettled();
|
|
943
|
+
if (isStale(token)) return renderMode;
|
|
798
944
|
enterRealistic(rig);
|
|
799
945
|
realisticPending = false;
|
|
800
946
|
publishMode();
|
|
@@ -839,6 +985,32 @@ export function createViewer(container, part) {
|
|
|
839
985
|
for (const [n, m] of physicalMats) m.userData.patternUniforms?.pfPrintFrame.value.fromArray(printFrames[n] ?? identity);
|
|
840
986
|
}
|
|
841
987
|
|
|
988
|
+
// The lazy form mount uses. Computing a frame runs the sub-part's pose probe
|
|
989
|
+
// (two geometry-free builds for a placed sub-part), and every sub-part with
|
|
990
|
+
// no material draws layer lines, so frames are PULLED only when something is
|
|
991
|
+
// about to draw the realistic look: the live view (or a switch loading),
|
|
992
|
+
// and any capture that borrows it — compileRealistic and enterRealistic are
|
|
993
|
+
// the two doors every such path goes through. A delivery just marks them
|
|
994
|
+
// stale (invalidatePrintFrames); in CAD nothing is computed.
|
|
995
|
+
function setPrintFrameSource(source) {
|
|
996
|
+
printFrameSource = typeof source === "function" ? source : null;
|
|
997
|
+
invalidatePrintFrames();
|
|
998
|
+
}
|
|
999
|
+
function invalidatePrintFrames() {
|
|
1000
|
+
printFramesStale = true;
|
|
1001
|
+
if (renderMode === "realistic" || realisticPending) syncPrintFrames();
|
|
1002
|
+
}
|
|
1003
|
+
function syncPrintFrames() {
|
|
1004
|
+
if (!printFrameSource || !printFramesStale) return;
|
|
1005
|
+
printFramesStale = false;
|
|
1006
|
+
let frames;
|
|
1007
|
+
try { frames = printFrameSource(); } catch (e) {
|
|
1008
|
+
console.warn("partforge: computing print frames failed", e);
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
setPrintFrames(frames);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
842
1014
|
// Resolves once the current environment's rig is loaded (captures wait on it).
|
|
843
1015
|
const whenRealisticReady = () => rigFor(environmentId).then(() => {});
|
|
844
1016
|
|
|
@@ -849,10 +1021,12 @@ export function createViewer(container, part) {
|
|
|
849
1021
|
for (const p of rigCache.values()) p.then((r) => r.dispose(), () => {});
|
|
850
1022
|
rigCache.clear();
|
|
851
1023
|
loadedRigs.clear();
|
|
1024
|
+
borrowedRigRefs.clear();
|
|
852
1025
|
for (const m of physicalMats.values()) m.dispose();
|
|
853
1026
|
physicalMats.clear();
|
|
854
1027
|
for (const t of textureCache.values()) t.dispose();
|
|
855
1028
|
textureCache.clear();
|
|
1029
|
+
pendingTextures.clear();
|
|
856
1030
|
pmrem?.dispose();
|
|
857
1031
|
pmrem = null;
|
|
858
1032
|
}
|
|
@@ -864,6 +1038,10 @@ export function createViewer(container, part) {
|
|
|
864
1038
|
const frameListeners = new Set();
|
|
865
1039
|
function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
|
|
866
1040
|
|
|
1041
|
+
// Fired at the end of every showAssembly, for hosts that need to react to
|
|
1042
|
+
// which sub-parts are visible (e.g. re-deriving a view style thumbnail).
|
|
1043
|
+
const assemblyListeners = new Set();
|
|
1044
|
+
|
|
867
1045
|
const camTween = createCameraTween();
|
|
868
1046
|
|
|
869
1047
|
// OrbitControls' damping is a rotational RESIDUAL, not a per-frame effect: it
|
|
@@ -912,7 +1090,23 @@ export function createViewer(container, part) {
|
|
|
912
1090
|
// the tween would re-zoom on every frame of a 0.6s cue (see setCameraState's
|
|
913
1091
|
// comment, which is where that reasoning is written down). Composed with the
|
|
914
1092
|
// caller's own onComplete rather than replacing it.
|
|
915
|
-
|
|
1093
|
+
//
|
|
1094
|
+
// `autoProjection` is the view cube's alone (its canvas clicks and its
|
|
1095
|
+
// per-face keyboard buttons): a tween that lands on a FACE view — straight
|
|
1096
|
+
// down a world axis — settles into orthographic at the end. Deliberately an
|
|
1097
|
+
// explicit option rather than inferred from the view name, because an
|
|
1098
|
+
// animation camera cue to "front" means "look from the front", not "switch
|
|
1099
|
+
// projection"; cues never pass it, so they never ENTER ortho.
|
|
1100
|
+
//
|
|
1101
|
+
// Leaving ortho is the same for every tween: one whose destination is not a
|
|
1102
|
+
// face view (an edge, a corner, iso) swaps back to perspective at the START.
|
|
1103
|
+
// At the start because the part is still seen head-on there, where the two
|
|
1104
|
+
// projections differ least, and the rotation that follows then reads as the
|
|
1105
|
+
// ordinary perspective orbit it is; swapping at the end instead would spin
|
|
1106
|
+
// the part flat and then pop the depth in at an oblique angle, where the
|
|
1107
|
+
// difference is largest. A tween between two faces while ortho stays ortho
|
|
1108
|
+
// the whole way — no projection change at all, rather than two in 0.6s.
|
|
1109
|
+
function tweenCameraTo(viewName, { duration = 0.6, onComplete, refit = false, autoProjection = false } = {}) {
|
|
916
1110
|
const box = getVisibleWorldBounds();
|
|
917
1111
|
if (!box || box.isEmpty()) { onComplete?.(); return; }
|
|
918
1112
|
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
@@ -920,11 +1114,24 @@ export function createViewer(container, part) {
|
|
|
920
1114
|
// radius = full max extent (not half), matching frameTo's framing distance so a
|
|
921
1115
|
// live camera cue doesn't land twice as close as the reframe button and crop the part.
|
|
922
1116
|
const pose = cameraPoseForView(viewName, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
|
|
1117
|
+
const toDir = new THREE.Vector3().fromArray(pose.position).sub(new THREE.Vector3().fromArray(pose.target));
|
|
1118
|
+
const toFace = isFaceAligned(toDir.toArray());
|
|
1119
|
+
if (!toFace && projectionMode === "orthographic") setProjection("perspective");
|
|
923
1120
|
// The projection is read at COMPLETION, not now: a 0.6s tween is long
|
|
924
|
-
// enough for
|
|
1121
|
+
// enough for a host to have changed it under it.
|
|
925
1122
|
const finish = () => {
|
|
926
1123
|
restoreDamping();
|
|
927
|
-
if (
|
|
1124
|
+
if (autoProjection && toFace && projectionMode !== "orthographic") {
|
|
1125
|
+
// Settle into the face view. The tween fires onComplete from inside its
|
|
1126
|
+
// own update(), BEFORE the render loop writes the final pose onto the
|
|
1127
|
+
// camera, so put it there first — the render loop then writes the same
|
|
1128
|
+
// values onto the (now orthographic) camera. setProjection sizes the
|
|
1129
|
+
// frustum from the camera's distance, which is the pose's framing
|
|
1130
|
+
// distance, so this is also the refit, with zoom 1.
|
|
1131
|
+
activeCamera.position.fromArray(pose.position);
|
|
1132
|
+
controls.target.fromArray(pose.target);
|
|
1133
|
+
setProjection("orthographic");
|
|
1134
|
+
} else if (refit && projectionMode === "orthographic") {
|
|
928
1135
|
// The tween fires onComplete from inside its own update(), BEFORE the
|
|
929
1136
|
// render loop writes the final pose onto the camera — so the camera is
|
|
930
1137
|
// still a frame short of where it is going. Frame from the distance
|
|
@@ -932,8 +1139,14 @@ export function createViewer(container, part) {
|
|
|
932
1139
|
syncOrthoToPerspectiveFraming({
|
|
933
1140
|
distance: new THREE.Vector3().fromArray(pose.position)
|
|
934
1141
|
.distanceTo(new THREE.Vector3().fromArray(pose.target)),
|
|
1142
|
+
target: new THREE.Vector3().fromArray(pose.target),
|
|
1143
|
+
direction: toDir,
|
|
935
1144
|
});
|
|
936
1145
|
}
|
|
1146
|
+
// Still ortho at the end (a face-to-face tween, a cue onto a face, or the
|
|
1147
|
+
// switch just above): the face this tween landed on is the axis a
|
|
1148
|
+
// rotation now has to leave.
|
|
1149
|
+
if (projectionMode === "orthographic") orthoAxis = toDir.clone();
|
|
937
1150
|
onComplete?.();
|
|
938
1151
|
};
|
|
939
1152
|
suspendDamping();
|
|
@@ -997,20 +1210,189 @@ export function createViewer(container, part) {
|
|
|
997
1210
|
orthoCamera.updateProjectionMatrix();
|
|
998
1211
|
}
|
|
999
1212
|
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1213
|
+
// Where a projection swap matches size. The two projections agree on the
|
|
1214
|
+
// size of things at exactly ONE depth — an orthographic view draws every
|
|
1215
|
+
// depth at one scale, a perspective one magnifies whatever is nearer — so the
|
|
1216
|
+
// swap has to choose it, and it chooses the surface the user is looking AT:
|
|
1217
|
+
// the first visible surface under the screen centre, found by a ray down the
|
|
1218
|
+
// view axis through the orbit target. It used to be the target's own depth
|
|
1219
|
+
// (the part's CENTRE, for a view cube face), while a face view shows the
|
|
1220
|
+
// part's FRONT: the swap back to perspective then magnified the surface on
|
|
1221
|
+
// screen by distance / (distance - depth), and since that distance is the
|
|
1222
|
+
// ortho zoom recovered as a dolly, zooming in made the jump grow (a planter
|
|
1223
|
+
// zoomed 2.27x jumped 22% wider and 53% taller).
|
|
1224
|
+
//
|
|
1225
|
+
// Returned as the signed distance of that surface from the target, along
|
|
1226
|
+
// `towardCamera` (target -> camera). Positive is in front of the target.
|
|
1227
|
+
// - What is visible is what is DRAWN: an opaque sub-part's front face
|
|
1228
|
+
// that survives the cutaway, or — with the cutaway on — the section cap
|
|
1229
|
+
// where the ray crosses the plane inside solid material (the cap is not a
|
|
1230
|
+
// sub-part mesh, so the ray finds it by parity rather than by a hit).
|
|
1231
|
+
// Ghosted sub-parts (a static display opacity below 1) are seen through.
|
|
1232
|
+
// - `maxDepth` is where a PERSPECTIVE camera sits: it cannot see anything
|
|
1233
|
+
// behind itself (an orthographic one can — its near plane may be
|
|
1234
|
+
// negative, see depth-range.js — so the exit swap searches the whole
|
|
1235
|
+
// line). A surface within MATCH_CLEARANCE of the camera is no surface
|
|
1236
|
+
// to match at: the target's depth is kept instead.
|
|
1237
|
+
// - Nothing on the ray (a hole, a gap between bodies, an edge-on sheet)
|
|
1238
|
+
// falls back to the front of the visible bounds: in a face view, the
|
|
1239
|
+
// nearest face of the part.
|
|
1240
|
+
// - Nothing at all shown: 0, the target's depth, as before.
|
|
1241
|
+
const _matchRaycaster = new THREE.Raycaster();
|
|
1242
|
+
const _matchDir = new THREE.Vector3();
|
|
1243
|
+
const _matchOrigin = new THREE.Vector3();
|
|
1244
|
+
const _matchCorner = new THREE.Vector3();
|
|
1245
|
+
const _matchSphere = new THREE.Sphere();
|
|
1246
|
+
const _matchNormal = new THREE.Vector3();
|
|
1247
|
+
const _matchNormalMat = new THREE.Matrix3();
|
|
1248
|
+
const _matchPlane = new THREE.Plane();
|
|
1249
|
+
const _equivDir = new THREE.Vector3();
|
|
1250
|
+
// Both sides of every triangle: the parity walk has to see the faces the ray
|
|
1251
|
+
// LEAVES a solid through, which a FrontSide material hides from a raycast.
|
|
1252
|
+
const _matchProxy = new THREE.Mesh(undefined, new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }));
|
|
1253
|
+
_matchProxy.matrixAutoUpdate = false;
|
|
1254
|
+
const MATCH_CLEARANCE = 1e-3; // of the visible bounds' diagonal
|
|
1255
|
+
// The ray's candidate surfaces are remembered per RAY and SCENE: the target,
|
|
1256
|
+
// the direction, the cutaway's plane (null while off — so a toggle, drag,
|
|
1257
|
+
// flip or reset asks afresh), and a generation bumped by every geometry,
|
|
1258
|
+
// pose or visibility change (setSubGeometry, setSubPose, showAssembly,
|
|
1259
|
+
// hideAssembly) — a regenerated pocket or a hidden inner sub-part can move
|
|
1260
|
+
// the surface without moving the bounds. Swapping in and back out asks
|
|
1261
|
+
// about the same ray, but not bit-for-bit: controls.update() re-derives the
|
|
1262
|
+
// camera position from spherical coordinates on every swap, so the
|
|
1263
|
+
// direction comes back an ulp off — and a ray through a triangle edge (a
|
|
1264
|
+
// target at the centre of a symmetric part is exactly that) can hit on one
|
|
1265
|
+
// side of the ulp and miss on the other, which turned an untouched round
|
|
1266
|
+
// trip into a 25% reframe. What is remembered is the whole candidate list,
|
|
1267
|
+
// not one answer, so a query from a different camera position (`maxDepth`)
|
|
1268
|
+
// still gets its own, correct answer from it.
|
|
1269
|
+
let matchMemo = null; // { generation, target, dir, plane, candidates, fallback, clearance }
|
|
1270
|
+
const RAY_EPS = 1e-9;
|
|
1271
|
+
const samePlane = (a, b) => (a === null || b === null ? a === b
|
|
1272
|
+
: a.normal.distanceTo(b.normal) <= RAY_EPS && Math.abs(a.constant - b.constant) <= RAY_EPS * (1 + Math.abs(a.constant)));
|
|
1273
|
+
function sizeMatchDepth(target, towardCamera, { maxDepth = Infinity } = {}) {
|
|
1274
|
+
if (towardCamera.lengthSq() === 0) return 0;
|
|
1275
|
+
const box = getVisibleWorldBounds();
|
|
1276
|
+
if (box.isEmpty()) return 0;
|
|
1277
|
+
_matchDir.copy(towardCamera).normalize();
|
|
1278
|
+
const plane = cutaway.getPlane?.(_matchPlane) ?? null;
|
|
1279
|
+
const tol = RAY_EPS * (1 + box.max.distanceTo(box.min));
|
|
1280
|
+
let m = matchMemo;
|
|
1281
|
+
if (!(m && m.generation === matchGeneration && m.target.distanceTo(target) <= tol
|
|
1282
|
+
&& m.dir.dot(_matchDir) >= 1 - RAY_EPS && samePlane(m.plane, plane))) {
|
|
1283
|
+
m = matchMemo = {
|
|
1284
|
+
generation: matchGeneration,
|
|
1285
|
+
target: target.clone(),
|
|
1286
|
+
dir: _matchDir.clone(),
|
|
1287
|
+
plane: plane && plane.clone(),
|
|
1288
|
+
...matchCandidates(target, box, plane),
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
// Candidates are nearest-the-camera first: the first one in front of the
|
|
1292
|
+
// camera, clear of it, is what the camera sees.
|
|
1293
|
+
for (const depth of m.candidates) {
|
|
1294
|
+
if (depth > maxDepth) continue; // behind a perspective camera
|
|
1295
|
+
return maxDepth - depth < m.clearance ? 0 : depth;
|
|
1296
|
+
}
|
|
1297
|
+
if (maxDepth - m.fallback < m.clearance) return 0;
|
|
1298
|
+
return m.fallback;
|
|
1299
|
+
}
|
|
1300
|
+
function matchCandidates(target, box, plane) {
|
|
1301
|
+
// Started outside everything shown rather than at the camera, so the ray
|
|
1302
|
+
// does not depend on where a perspective camera happens to be (an ortho
|
|
1303
|
+
// camera's position is a direction more than a place).
|
|
1304
|
+
box.getBoundingSphere(_matchSphere);
|
|
1305
|
+
const outside = target.distanceTo(_matchSphere.center) + _matchSphere.radius + 1;
|
|
1306
|
+
_matchOrigin.copy(target).addScaledVector(_matchDir, outside);
|
|
1307
|
+
const rayDir = _matchDir.clone().negate(); // camera -> target
|
|
1308
|
+
_matchRaycaster.set(_matchOrigin, rayDir);
|
|
1309
|
+
_matchRaycaster.far = 2 * outside;
|
|
1310
|
+
const hits = [];
|
|
1311
|
+
for (const n of names) {
|
|
1312
|
+
const mesh = subMesh[n];
|
|
1313
|
+
if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
|
|
1314
|
+
if ((part.parts[n].display?.opacity ?? 1) < 1) continue; // a ghost is seen through
|
|
1315
|
+
mesh.updateWorldMatrix(true, false);
|
|
1316
|
+
_matchProxy.geometry = mesh.geometry;
|
|
1317
|
+
_matchProxy.matrixWorld.copy(mesh.matrixWorld);
|
|
1318
|
+
_matchNormalMat.getNormalMatrix(mesh.matrixWorld);
|
|
1319
|
+
for (const hit of _matchRaycaster.intersectObject(_matchProxy, false)) {
|
|
1320
|
+
_matchNormal.copy(hit.face.normal).applyMatrix3(_matchNormalMat);
|
|
1321
|
+
hits.push({ t: hit.distance, point: hit.point, entering: _matchNormal.dot(rayDir) < 0 });
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
_matchProxy.geometry = undefined;
|
|
1325
|
+
hits.sort((x, y) => x.t - y.t);
|
|
1326
|
+
// Where the ray crosses the cut plane, heading INTO the half that is kept:
|
|
1327
|
+
// a cap is drawn there if the crossing is inside solid material.
|
|
1328
|
+
let capT = null;
|
|
1329
|
+
if (plane) {
|
|
1330
|
+
const denom = plane.normal.dot(rayDir);
|
|
1331
|
+
if (denom > 0) {
|
|
1332
|
+
const t = -(plane.normal.dot(_matchOrigin) + plane.constant) / denom;
|
|
1333
|
+
if (t > 0) capT = t;
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
const candidates = [];
|
|
1337
|
+
let inside = 0;
|
|
1338
|
+
let capDone = capT === null;
|
|
1339
|
+
for (const hit of hits) {
|
|
1340
|
+
if (!capDone && hit.t >= capT) {
|
|
1341
|
+
capDone = true;
|
|
1342
|
+
if (inside > 0) candidates.push(outside - capT);
|
|
1343
|
+
}
|
|
1344
|
+
if (hit.entering && (cutaway.isPointVisible?.(hit.point) ?? true)) candidates.push(outside - hit.t);
|
|
1345
|
+
inside += hit.entering ? 1 : -1;
|
|
1346
|
+
}
|
|
1347
|
+
let fallback = -Infinity;
|
|
1348
|
+
for (let i = 0; i < 8; i++) {
|
|
1349
|
+
_matchCorner.set(i & 1 ? box.max.x : box.min.x, i & 2 ? box.max.y : box.min.y, i & 4 ? box.max.z : box.min.z);
|
|
1350
|
+
fallback = Math.max(fallback, _matchCorner.sub(target).dot(_matchDir));
|
|
1351
|
+
}
|
|
1352
|
+
return {
|
|
1353
|
+
candidates,
|
|
1354
|
+
fallback: Number.isFinite(fallback) ? fallback : 0,
|
|
1355
|
+
clearance: MATCH_CLEARANCE * box.max.distanceTo(box.min),
|
|
1356
|
+
};
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
// The perspective distance (target -> camera) that shows what the ortho
|
|
1360
|
+
// camera shows now, at the size-match surface above. OrbitControls zooms an
|
|
1361
|
+
// ortho camera through camera.zoom rather than by moving it, which is why
|
|
1362
|
+
// this reads the zoom. Falls back to the target's depth if the surface is so
|
|
1363
|
+
// far BEHIND the target that the camera would have to sit behind the target
|
|
1364
|
+
// to match it (a target panned out in front of everything).
|
|
1365
|
+
function orthoEquivalentDistance() {
|
|
1366
|
+
const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
|
|
1367
|
+
// `|| 1` on the zoom for the same reason captureCurrent guards it: a zero
|
|
1368
|
+
// would make this non-finite.
|
|
1369
|
+
const atSurface = perspectiveDistance({ halfH, zoom: orthoCamera.zoom || 1, fovDeg: camera.fov });
|
|
1370
|
+
const depth = sizeMatchDepth(controls.target, _equivDir.copy(orthoCamera.position).sub(controls.target));
|
|
1371
|
+
const distance = atSurface + depth;
|
|
1372
|
+
return distance > atSurface * 0.05 ? distance : atSurface;
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
// Re-derive the ortho frustum from the perspective camera's fov so that it
|
|
1376
|
+
// shows what a perspective camera at the CURRENT distance from the orbit
|
|
1377
|
+
// target shows at the size-match surface (sizeMatchDepth). Called on every
|
|
1378
|
+
// swap into ortho and after any reframe, which is what keeps the two
|
|
1379
|
+
// projections showing the same amount of part.
|
|
1004
1380
|
//
|
|
1005
|
-
// `distance`
|
|
1006
|
-
// completion callback, which fires one frame
|
|
1007
|
-
// camera, so it frames from the
|
|
1008
|
-
// the camera happens to be at that instant.
|
|
1009
|
-
|
|
1010
|
-
|
|
1381
|
+
// `distance` / `target` / `direction` override "current": tweenCameraTo's
|
|
1382
|
+
// refit runs from the tween's completion callback, which fires one frame
|
|
1383
|
+
// BEFORE the final pose reaches the camera, so it frames from the pose it
|
|
1384
|
+
// asked for rather than from where the camera happens to be at that instant.
|
|
1385
|
+
const _syncTarget = new THREE.Vector3();
|
|
1386
|
+
const _syncDir = new THREE.Vector3();
|
|
1387
|
+
function syncOrthoToPerspectiveFraming({ distance: atDistance, target, direction } = {}) {
|
|
1388
|
+
_syncTarget.copy(target ?? controls.target);
|
|
1389
|
+
if (direction) _syncDir.copy(direction);
|
|
1390
|
+
else _syncDir.copy(activeCamera.position).sub(_syncTarget);
|
|
1391
|
+
const distance = atDistance || _syncDir.length() || 1;
|
|
1392
|
+
const depth = sizeMatchDepth(_syncTarget, _syncDir, { maxDepth: distance });
|
|
1011
1393
|
applyOrthoFrustum(orthoFrustum({
|
|
1012
1394
|
fovDeg: camera.fov,
|
|
1013
|
-
distance,
|
|
1395
|
+
distance: distance - depth,
|
|
1014
1396
|
aspect: camera.aspect || 1,
|
|
1015
1397
|
}));
|
|
1016
1398
|
// The frustum now expresses the whole framing, so any dolly-by-zoom the user
|
|
@@ -1035,7 +1417,8 @@ export function createViewer(container, part) {
|
|
|
1035
1417
|
} else {
|
|
1036
1418
|
// Recover whatever dolly the user did while in ortho: OrbitControls
|
|
1037
1419
|
// changes camera.zoom there rather than moving the camera, so the zoom
|
|
1038
|
-
// has to come back as a distance or the part jumps size
|
|
1420
|
+
// has to come back as a distance or the part jumps size — matched at the
|
|
1421
|
+
// surface the user is looking at (orthoEquivalentDistance).
|
|
1039
1422
|
//
|
|
1040
1423
|
// The bound exists because ortho zoom is UNBOUNDED and zooming a long way
|
|
1041
1424
|
// out costs nothing there (an ortho projection has no depth falloff) —
|
|
@@ -1046,17 +1429,15 @@ export function createViewer(container, part) {
|
|
|
1046
1429
|
// which is the camera that is still live and therefore the one
|
|
1047
1430
|
// updateDepthRange has been keeping current. `far * 0.9` alone would be
|
|
1048
1431
|
// too eager: frameTo frames at 2.6r + 6 MILLIMETRES, so an everyday 300mm
|
|
1049
|
-
// part sits at 786mm and a plain
|
|
1432
|
+
// part sits at 786mm and a plain swap would silently reframe it closer.
|
|
1050
1433
|
// Hence the max with the distance the camera is already at, which makes an
|
|
1051
|
-
// untouched round trip (zoom === 1, where
|
|
1052
|
-
// are exact inverses) lossless for a part of ANY
|
|
1053
|
-
// a degenerate zoom move the camera further
|
|
1054
|
-
//
|
|
1055
|
-
// would make this non-finite.
|
|
1056
|
-
const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
|
|
1434
|
+
// untouched round trip (zoom === 1, where the entry and this exit match at
|
|
1435
|
+
// the same surface and are exact inverses) lossless for a part of ANY
|
|
1436
|
+
// size, and still never lets a degenerate zoom move the camera further
|
|
1437
|
+
// out than it already was.
|
|
1057
1438
|
const offset = from.position.clone().sub(controls.target);
|
|
1058
1439
|
const distance = Math.min(
|
|
1059
|
-
|
|
1440
|
+
orthoEquivalentDistance(),
|
|
1060
1441
|
Math.max(from.far * 0.9, offset.length()),
|
|
1061
1442
|
);
|
|
1062
1443
|
camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
|
|
@@ -1080,12 +1461,16 @@ export function createViewer(container, part) {
|
|
|
1080
1461
|
// controls.update() ends in Object3D.lookAt, which does refresh matrixWorld
|
|
1081
1462
|
// — but it refreshes BEFORE writing the new quaternion, so a rotation
|
|
1082
1463
|
// applied inside that same update (damping momentum still decaying as the
|
|
1083
|
-
//
|
|
1464
|
+
// swap lands) leaves the rotation one frame behind. One matrix compose is
|
|
1084
1465
|
// cheaper than depending on that ordering. Placed after controls.update()
|
|
1085
1466
|
// for the same reason: it is the last writer of the pose.
|
|
1086
1467
|
to.updateMatrixWorld();
|
|
1087
1468
|
cutaway.setCamera(to);
|
|
1088
1469
|
projectionMode = next;
|
|
1470
|
+
// Ortho is armed on the direction it was entered on, whoever entered it (a
|
|
1471
|
+
// face click, a host's runtime.projection.set, a restore): the first
|
|
1472
|
+
// rotation off it returns to perspective the same way for all of them.
|
|
1473
|
+
orthoAxis = next === "orthographic" ? to.position.clone().sub(controls.target) : null;
|
|
1089
1474
|
for (const cb of [...projectionListeners]) cb(projectionMode);
|
|
1090
1475
|
return projectionMode;
|
|
1091
1476
|
}
|
|
@@ -1095,6 +1480,30 @@ export function createViewer(container, part) {
|
|
|
1095
1480
|
return () => projectionListeners.delete(cb);
|
|
1096
1481
|
}
|
|
1097
1482
|
|
|
1483
|
+
// Run every frame, after controls.update() and the tween have written the
|
|
1484
|
+
// pose: the first frame the view direction has turned off the ortho axis is a
|
|
1485
|
+
// ROTATION (a canvas drag, the cube's drag, a user grab that cancelled a tween
|
|
1486
|
+
// mid-turn), and it swaps back to perspective, size-preserving, from the
|
|
1487
|
+
// pose it has only just left. Direction rather than OrbitControls' "start"
|
|
1488
|
+
// event, because "start" also fires for pan and zoom, which keep the face
|
|
1489
|
+
// view. Here in the render loop rather than in a controls "change" listener
|
|
1490
|
+
// so setProjection's own controls.update() never runs nested inside one.
|
|
1491
|
+
// Skipped while a tween is in flight: it owns the camera, and decides the
|
|
1492
|
+
// projection itself (tweenCameraTo).
|
|
1493
|
+
// Allocation-free (it runs every frame while ortho): one scratch vector and
|
|
1494
|
+
// a dot product against the cosine of projection.js's epsilon — the same
|
|
1495
|
+
// test as hasLeftAxis, inlined.
|
|
1496
|
+
const _autoDir = new THREE.Vector3();
|
|
1497
|
+
const _axisUnit = new THREE.Vector3();
|
|
1498
|
+
function checkAutoProjection() {
|
|
1499
|
+
if (projectionMode !== "orthographic" || !orthoAxis || camTween.isActive()) return;
|
|
1500
|
+
_autoDir.copy(activeCamera.position).sub(controls.target);
|
|
1501
|
+
const len = _autoDir.length(), axisLen = orthoAxis.length();
|
|
1502
|
+
if (len === 0 || axisLen === 0) return;
|
|
1503
|
+
_axisUnit.copy(orthoAxis).divideScalar(axisLen);
|
|
1504
|
+
if (_autoDir.dot(_axisUnit) / len < FACE_ALIGN_COS) setProjection("perspective");
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1098
1507
|
// Fallback creasing for payloads with no kernel normals. Both backends now
|
|
1099
1508
|
// ship authoritative normals (Manifold: policy-aware crease pass; OCCT:
|
|
1100
1509
|
// analytic B-rep normals), so this path is last-ditch only — it must not be
|
|
@@ -1137,12 +1546,14 @@ export function createViewer(container, part) {
|
|
|
1137
1546
|
const subCache = Object.fromEntries(names.map((n) => [n, null]));
|
|
1138
1547
|
|
|
1139
1548
|
function setSubGeometry(name, payload) {
|
|
1549
|
+
matchGeneration++;
|
|
1140
1550
|
setSubPose(name, null); // fresh worker mesh is baked at current params — clear any fast-path pose
|
|
1141
1551
|
const prev = subCache[name];
|
|
1142
1552
|
const next = buildGeometry(payload);
|
|
1143
1553
|
// Brushed metal needs UVs for its tangent frame; CAD meshes carry none.
|
|
1144
1554
|
if (renderMode === "realistic" && physicalMats.get(name)?.userData.pfAnisotropic) ensureBoxUVs(next);
|
|
1145
1555
|
subCache[name] = next;
|
|
1556
|
+
syncGrain(name);
|
|
1146
1557
|
// Section helpers must stop referring to the old buffers before those
|
|
1147
1558
|
// buffers are released.
|
|
1148
1559
|
cutaway.updateGeometry(name, next);
|
|
@@ -1154,6 +1565,7 @@ export function createViewer(container, part) {
|
|
|
1154
1565
|
// three.js Matrix4 convention). Never affects exports or geometry — the worker
|
|
1155
1566
|
// owns real placement; this only re-poses the delivered mesh.
|
|
1156
1567
|
function setSubPose(name, mat16) {
|
|
1568
|
+
matchGeneration++;
|
|
1157
1569
|
for (const obj of [subMesh[name], subLines[name]]) {
|
|
1158
1570
|
if (!obj) continue;
|
|
1159
1571
|
obj.matrixAutoUpdate = false;
|
|
@@ -1173,6 +1585,7 @@ export function createViewer(container, part) {
|
|
|
1173
1585
|
// --- show / hide assembly -------------------------------------------------
|
|
1174
1586
|
const _box = new THREE.Box3();
|
|
1175
1587
|
const _posedBox = new THREE.Box3();
|
|
1588
|
+
const _frameDir = new THREE.Vector3();
|
|
1176
1589
|
|
|
1177
1590
|
// Recentre the assembly on the pivot and frame the camera to the named parts.
|
|
1178
1591
|
// Cached bounding boxes are in the delivered mesh's own frame, so any fast-path
|
|
@@ -1193,7 +1606,16 @@ export function createViewer(container, part) {
|
|
|
1193
1606
|
floorY = -size.z / 2;
|
|
1194
1607
|
grid.position.y = floorY;
|
|
1195
1608
|
const r = Math.max(size.x, size.y, size.z) || 12;
|
|
1196
|
-
|
|
1609
|
+
// Keep the VIEW DIRECTION (target -> camera), not the camera's direction
|
|
1610
|
+
// from the origin: after a pan the target is off the origin, and scaling
|
|
1611
|
+
// the position about the origin would turn the view — which, in an ortho
|
|
1612
|
+
// face view, reads as a rotation and drops to perspective at an off-axis
|
|
1613
|
+
// angle on the next frame. Falls back to the old origin-relative direction
|
|
1614
|
+
// for a degenerate offset (camera on its target).
|
|
1615
|
+
_frameDir.copy(activeCamera.position).sub(controls.target);
|
|
1616
|
+
if (_frameDir.lengthSq() === 0) _frameDir.copy(activeCamera.position);
|
|
1617
|
+
if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
|
|
1618
|
+
activeCamera.position.copy(_frameDir.normalize().multiplyScalar(r * 2.6 + 6));
|
|
1197
1619
|
controls.target.set(0, 0, 0);
|
|
1198
1620
|
// Framing under ortho is a frustum, not a distance — without this the
|
|
1199
1621
|
// reframe button moves the camera and nothing visibly changes.
|
|
@@ -1204,6 +1626,7 @@ export function createViewer(container, part) {
|
|
|
1204
1626
|
// frame the camera to them — done only on the initial show and on view (tab)
|
|
1205
1627
|
// changes, NOT on regeneration, so a user's zoom/orbit survives editing params.
|
|
1206
1628
|
function showAssembly(visibleNames, { frame = false } = {}) {
|
|
1629
|
+
matchGeneration++;
|
|
1207
1630
|
lastShown = [...visibleNames];
|
|
1208
1631
|
for (const name of names) {
|
|
1209
1632
|
if (visibleNames.includes(name)) {
|
|
@@ -1218,6 +1641,9 @@ export function createViewer(container, part) {
|
|
|
1218
1641
|
if (frame) frameTo(visibleNames);
|
|
1219
1642
|
cutaway.setVisible(effectiveVisible());
|
|
1220
1643
|
placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
|
|
1644
|
+
for (const cb of [...assemblyListeners]) {
|
|
1645
|
+
try { cb(); } catch (e) { console.warn("partforge: assembly listener failed", e); }
|
|
1646
|
+
}
|
|
1221
1647
|
}
|
|
1222
1648
|
|
|
1223
1649
|
// Re-frame whatever is currently visible (the reframe button).
|
|
@@ -1273,6 +1699,7 @@ export function createViewer(container, part) {
|
|
|
1273
1699
|
}
|
|
1274
1700
|
|
|
1275
1701
|
function hideAssembly() {
|
|
1702
|
+
matchGeneration++;
|
|
1276
1703
|
lastShown = [];
|
|
1277
1704
|
for (const m of Object.values(subMesh)) m.visible = false;
|
|
1278
1705
|
for (const l of Object.values(subLines)) l.visible = false;
|
|
@@ -1435,7 +1862,7 @@ export function createViewer(container, part) {
|
|
|
1435
1862
|
for (const dot of [...flashDots, ...captureHidden]) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
|
|
1436
1863
|
try {
|
|
1437
1864
|
renderer.setRenderTarget(rt);
|
|
1438
|
-
|
|
1865
|
+
renderWithBackdrop(renderScene, cam);
|
|
1439
1866
|
// render() resolves the multisample renderbuffer into the target texture, so this
|
|
1440
1867
|
// reads antialiased pixels.
|
|
1441
1868
|
renderer.readRenderTargetPixels(rt, 0, 0, width, height, buf);
|
|
@@ -1470,6 +1897,14 @@ export function createViewer(container, part) {
|
|
|
1470
1897
|
// Pointer feedback excluded from EVERY offscreen render (renderOffscreen),
|
|
1471
1898
|
// showcase captures included — unlike canonicalCaptureHidden below.
|
|
1472
1899
|
const captureHidden = new Set();
|
|
1900
|
+
// The cutaway's ghost plane is its gizmo, not its section: the part as the
|
|
1901
|
+
// user sees it (clipped, with its hatched caps) goes into a capture, the
|
|
1902
|
+
// translucent plane in front of it does not. It sits between the camera and
|
|
1903
|
+
// everything the cut keeps, and a capture blends it in linear light, where
|
|
1904
|
+
// it lands several times stronger than on the canvas — the view style
|
|
1905
|
+
// popover's thumbnails came back with the part washed out behind a blue
|
|
1906
|
+
// sheet, a dark part all but gone.
|
|
1907
|
+
for (const obj of cutaway.captureExcluded ?? []) captureHidden.add(obj);
|
|
1473
1908
|
function registerCaptureHidden(obj) {
|
|
1474
1909
|
captureHidden.add(obj);
|
|
1475
1910
|
return () => captureHidden.delete(obj);
|
|
@@ -1491,7 +1926,8 @@ export function createViewer(container, part) {
|
|
|
1491
1926
|
// renders, and the editor's on-screen mode must never change what the agent
|
|
1492
1927
|
// sees. A realistic live view lends its scene to CAD for the synchronous
|
|
1493
1928
|
// capture and gets it back exactly (captureIn). renderViews is the one way
|
|
1494
|
-
// to ask for realistic canonical views.
|
|
1929
|
+
// to ask for realistic canonical views. Feature lines are CAD-only, so
|
|
1930
|
+
// borrowing CAD for this capture draws them for free.
|
|
1495
1931
|
function captureCanonicalViews(viewNames) {
|
|
1496
1932
|
if (disposed) return [];
|
|
1497
1933
|
const box = getVisibleWorldBounds();
|
|
@@ -1598,20 +2034,75 @@ export function createViewer(container, part) {
|
|
|
1598
2034
|
}
|
|
1599
2035
|
}
|
|
1600
2036
|
|
|
2037
|
+
// One small render of the CURRENT framing in any style, for the view style
|
|
2038
|
+
// popover. The live view is put back exactly (same contract as captureIn:
|
|
2039
|
+
// no publish, nothing persisted), whether it is CAD, or realistic in this
|
|
2040
|
+
// or another environment. The borrowed style brings its own feature-lines
|
|
2041
|
+
// visibility with it for free, since it's decided by render mode alone.
|
|
2042
|
+
async function renderStyleThumbnail(style, { size = 256 } = {}) {
|
|
2043
|
+
if (disposed) return null;
|
|
2044
|
+
const box = getVisibleWorldBounds();
|
|
2045
|
+
if (!box || box.isEmpty()) return null;
|
|
2046
|
+
const capture = () => currentFramingInCurrentLook({ size, quality: 0.8 });
|
|
2047
|
+
if (style === "cad") return captureIn("cad", null, capture);
|
|
2048
|
+
const id = resolveEnvironmentId(style).id;
|
|
2049
|
+
// Borrowed BEFORE awaiting (see releaseRig).
|
|
2050
|
+
const p = borrowRig(id);
|
|
2051
|
+
try {
|
|
2052
|
+
const rig = await p;
|
|
2053
|
+
if (disposed) return null;
|
|
2054
|
+
await compileRealistic(rig, { forCapture: true });
|
|
2055
|
+
if (disposed) return null;
|
|
2056
|
+
await whenTexturesSettled();
|
|
2057
|
+
if (disposed) return null;
|
|
2058
|
+
if (renderMode !== "realistic" || realisticRig === rig) return captureIn("realistic", rig, capture);
|
|
2059
|
+
// Realistic in another environment: borrow this rig, then put the live one back.
|
|
2060
|
+
const liveRig = realisticRig, movedAt = shadowMovedAt;
|
|
2061
|
+
try {
|
|
2062
|
+
enterRealistic(rig, { live: false });
|
|
2063
|
+
return capture();
|
|
2064
|
+
} finally {
|
|
2065
|
+
try {
|
|
2066
|
+
enterRealistic(liveRig, { live: false, reground: false });
|
|
2067
|
+
shadowMovedAt = movedAt;
|
|
2068
|
+
} catch (e) {
|
|
2069
|
+
// enterRealistic already rolled the scene back to CAD: make it the live mode.
|
|
2070
|
+
console.warn("partforge: restoring the realistic view after a thumbnail failed", e);
|
|
2071
|
+
try { applyPixelRatio("cad"); } catch { /* the view is CAD either way */ }
|
|
2072
|
+
publishMode({ error: "couldn't load realistic view" });
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
} finally {
|
|
2076
|
+
releaseRig(id, p);
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
2079
|
+
|
|
1601
2080
|
// Agent-facing canonical renders in a chosen appearance, whatever the live
|
|
1602
2081
|
// view shows. CAD is exactly captureCanonicalViews (which is always CAD).
|
|
1603
2082
|
// Realistic waits for the current environment's rig and the capture's
|
|
1604
2083
|
// shader programs, then borrows the realistic look for the synchronous
|
|
1605
2084
|
// capture only if the live view is not already realistic. A rig that fails
|
|
1606
|
-
// to load rejects rather than silently returning CAD images.
|
|
2085
|
+
// to load rejects rather than silently returning CAD images. Feature lines
|
|
2086
|
+
// are CAD-only, so borrowing the realistic look draws without them for free.
|
|
1607
2087
|
async function renderViews(viewNames, { renderMode: want = "cad" } = {}) {
|
|
1608
2088
|
if (disposed) return [];
|
|
1609
2089
|
if (want !== "realistic") return captureCanonicalViews(viewNames);
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
2090
|
+
// Borrowed like a thumbnail's rig, so a thumbnail of the same environment
|
|
2091
|
+
// finishing first cannot dispose it mid-capture (and, in CAD, it is freed
|
|
2092
|
+
// once this is done — it is not on screen).
|
|
2093
|
+
const id = environmentId;
|
|
2094
|
+
const p = borrowRig(id);
|
|
2095
|
+
try {
|
|
2096
|
+
const rig = await p;
|
|
2097
|
+
if (disposed) return [];
|
|
2098
|
+
await compileRealistic(rig, { forCapture: true });
|
|
2099
|
+
if (disposed) return [];
|
|
2100
|
+
await whenTexturesSettled();
|
|
2101
|
+
if (disposed) return [];
|
|
2102
|
+
return captureIn("realistic", rig, () => canonicalViewsInCurrentLook(viewNames));
|
|
2103
|
+
} finally {
|
|
2104
|
+
releaseRig(id, p);
|
|
2105
|
+
}
|
|
1615
2106
|
}
|
|
1616
2107
|
|
|
1617
2108
|
// Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
|
|
@@ -1676,7 +2167,7 @@ export function createViewer(container, part) {
|
|
|
1676
2167
|
try {
|
|
1677
2168
|
// fov comes from the PERSPECTIVE camera, deliberately, not from whichever
|
|
1678
2169
|
// camera is live: thumbnails are canonical captures and stay perspective
|
|
1679
|
-
//
|
|
2170
|
+
// whichever projection is live. cameraPoseForView's distance
|
|
1680
2171
|
// is tuned to this fov, so a narrower one would crop long, thin parts.
|
|
1681
2172
|
return renderOffscreen(
|
|
1682
2173
|
pose,
|
|
@@ -1698,6 +2189,64 @@ export function createViewer(container, part) {
|
|
|
1698
2189
|
}
|
|
1699
2190
|
}
|
|
1700
2191
|
|
|
2192
|
+
// --- the environment backdrop under an orthographic camera ---------------
|
|
2193
|
+
// three draws a texture `scene.background` (the realistic environment photo)
|
|
2194
|
+
// as a unit box around the camera, projected through the camera's own
|
|
2195
|
+
// projection (WebGLBackground + the backgroundCube shader). Under a
|
|
2196
|
+
// PerspectiveCamera that box fills the frame; under an OrthographicCamera it
|
|
2197
|
+
// projects to a one-unit square in a frustum tens of millimetres wide or
|
|
2198
|
+
// more — a speck behind the part — so an ortho face view (the view cube
|
|
2199
|
+
// settles into one) lost its whole backdrop to the clear colour, on the
|
|
2200
|
+
// canvas and in every capture of that framing. So under ortho the backdrop is
|
|
2201
|
+
// its own pass: three's own background draw (same PMREM, blur, intensity,
|
|
2202
|
+
// rotation) through a perspective camera with the ortho camera's orientation
|
|
2203
|
+
// and the live perspective camera's fov, then the scene through the ortho
|
|
2204
|
+
// camera on top, clearing depth and stencil but not colour. The backdrop is
|
|
2205
|
+
// exactly what perspective shows looking the same way. A colour background
|
|
2206
|
+
// (CAD) clears the same under either projection and keeps the one pass.
|
|
2207
|
+
// Residual: a transmissive material in the scene pass refracts the clear
|
|
2208
|
+
// colour rather than the environment (its transmission pass reads
|
|
2209
|
+
// scene.background, which is null for that pass) — under ortho only.
|
|
2210
|
+
const _backdropCam = new THREE.PerspectiveCamera(45, 1, 0.1, 10);
|
|
2211
|
+
const _backdropScene = new THREE.Scene();
|
|
2212
|
+
function renderWithBackdrop(renderScene, cam) {
|
|
2213
|
+
const background = renderScene.background;
|
|
2214
|
+
if (!cam.isOrthographicCamera || !background?.isTexture) {
|
|
2215
|
+
renderer.render(renderScene, cam);
|
|
2216
|
+
return;
|
|
2217
|
+
}
|
|
2218
|
+
cam.updateMatrixWorld();
|
|
2219
|
+
cam.matrixWorld.decompose(_backdropCam.position, _backdropCam.quaternion, _backdropCam.scale);
|
|
2220
|
+
_backdropCam.up.copy(cam.up);
|
|
2221
|
+
_backdropCam.fov = camera.fov;
|
|
2222
|
+
_backdropCam.aspect = (cam.right - cam.left) / (cam.top - cam.bottom) || 1;
|
|
2223
|
+
// A recentred capture renders a sub-window of a larger frame: the backdrop
|
|
2224
|
+
// is cropped the same way, so it lines up with the scene pass.
|
|
2225
|
+
if (cam.view?.enabled) {
|
|
2226
|
+
const { fullWidth, fullHeight, offsetX, offsetY, width, height } = cam.view;
|
|
2227
|
+
_backdropCam.setViewOffset(fullWidth, fullHeight, offsetX, offsetY, width, height);
|
|
2228
|
+
} else {
|
|
2229
|
+
_backdropCam.clearViewOffset();
|
|
2230
|
+
}
|
|
2231
|
+
_backdropCam.updateProjectionMatrix();
|
|
2232
|
+
_backdropCam.updateMatrixWorld(true);
|
|
2233
|
+
_backdropScene.background = background;
|
|
2234
|
+
_backdropScene.backgroundBlurriness = renderScene.backgroundBlurriness;
|
|
2235
|
+
_backdropScene.backgroundIntensity = renderScene.backgroundIntensity;
|
|
2236
|
+
_backdropScene.backgroundRotation.copy(renderScene.backgroundRotation);
|
|
2237
|
+
const clearColorWas = renderer.autoClearColor;
|
|
2238
|
+
try {
|
|
2239
|
+
renderer.render(_backdropScene, _backdropCam);
|
|
2240
|
+
renderScene.background = null;
|
|
2241
|
+
renderer.autoClearColor = false;
|
|
2242
|
+
renderer.render(renderScene, cam);
|
|
2243
|
+
} finally {
|
|
2244
|
+
renderScene.background = background;
|
|
2245
|
+
renderer.autoClearColor = clearColorWas;
|
|
2246
|
+
_backdropScene.background = null; // hold no reference to a rig's texture
|
|
2247
|
+
}
|
|
2248
|
+
}
|
|
2249
|
+
|
|
1701
2250
|
// --- render loop ----------------------------------------------------------
|
|
1702
2251
|
// The tween is applied after controls.update() so the cue wins the frame, and
|
|
1703
2252
|
// the frame listeners run before render so a playback frame draws its own pose.
|
|
@@ -1711,6 +2260,7 @@ export function createViewer(container, part) {
|
|
|
1711
2260
|
activeCamera.position.fromArray(tw.position);
|
|
1712
2261
|
controls.target.fromArray(tw.target);
|
|
1713
2262
|
}
|
|
2263
|
+
checkAutoProjection();
|
|
1714
2264
|
// Per-listener guard, because three re-arms requestAnimationFrame only AFTER
|
|
1715
2265
|
// this callback returns (WebGLAnimation.onAnimationFrame): a listener that
|
|
1716
2266
|
// throws would stop the rAF chain outright and freeze the viewer for good, not
|
|
@@ -1734,7 +2284,7 @@ export function createViewer(container, part) {
|
|
|
1734
2284
|
const next = projectPoint([anchorDot.position.x, anchorDot.position.y, anchorDot.position.z]);
|
|
1735
2285
|
if (anchorMoved(lastAnchor, next)) publishAnchor(next);
|
|
1736
2286
|
}
|
|
1737
|
-
|
|
2287
|
+
renderWithBackdrop(scene, activeCamera);
|
|
1738
2288
|
cutaway.renderOverlay(renderer, activeCamera);
|
|
1739
2289
|
}
|
|
1740
2290
|
renderer.setAnimationLoop(renderFrame);
|
|
@@ -1797,10 +2347,28 @@ export function createViewer(container, part) {
|
|
|
1797
2347
|
}
|
|
1798
2348
|
|
|
1799
2349
|
// --- camera state (read/write for persistence; mount.js owns storage) -------
|
|
2350
|
+
// Under ortho, `pos` is the PERSPECTIVE-EQUIVALENT position: same view
|
|
2351
|
+
// direction, at the distance whose perspective frame shows what the ortho
|
|
2352
|
+
// frustum shows now (halfH / zoom — setProjection's own conversion). An
|
|
2353
|
+
// ortho zoom never moves the camera, so the raw position would carry none of
|
|
2354
|
+
// it, and setCameraState — which re-derives the frustum from the distance at
|
|
2355
|
+
// zoom 1 — would bring a remount back at the unzoomed size. Every reader of
|
|
2356
|
+
// this state (viewerState's carry-over, the reload-persisted camera) means
|
|
2357
|
+
// "what the user was looking at", so they all want the equivalent. The live
|
|
2358
|
+
// ortho camera's own position is viewer.camera.position.
|
|
1800
2359
|
function getCameraState() {
|
|
2360
|
+
const t = controls.target;
|
|
2361
|
+
if (projectionMode === "orthographic") {
|
|
2362
|
+
const offset = activeCamera.position.clone().sub(t);
|
|
2363
|
+
if (offset.lengthSq() > 0) {
|
|
2364
|
+
const d = orthoEquivalentDistance();
|
|
2365
|
+
const p = offset.normalize().multiplyScalar(d).add(t);
|
|
2366
|
+
return { pos: [p.x, p.y, p.z], target: [t.x, t.y, t.z] };
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
1801
2369
|
return {
|
|
1802
2370
|
pos: [activeCamera.position.x, activeCamera.position.y, activeCamera.position.z],
|
|
1803
|
-
target: [
|
|
2371
|
+
target: [t.x, t.y, t.z],
|
|
1804
2372
|
};
|
|
1805
2373
|
}
|
|
1806
2374
|
function setCameraState({ pos, target }) {
|
|
@@ -1808,7 +2376,7 @@ export function createViewer(container, part) {
|
|
|
1808
2376
|
controls.target.set(target[0], target[1], target[2]);
|
|
1809
2377
|
controls.update();
|
|
1810
2378
|
// A saved pose carries an implied FRAMING, so the ortho frustum has to be
|
|
1811
|
-
// re-derived from the restored distance. Without it, a
|
|
2379
|
+
// re-derived from the restored distance. Without it, a remount in ortho comes
|
|
1812
2380
|
// back at the wrong zoom: the projection is restored during mount setup,
|
|
1813
2381
|
// while the camera is restored much later (showView, on the first accepted
|
|
1814
2382
|
// build), so the frustum would stay sized for wherever the camera happened
|
|
@@ -1822,7 +2390,18 @@ export function createViewer(container, part) {
|
|
|
1822
2390
|
// that really does mean "refit" opts in with `{ refit: true }`, which runs
|
|
1823
2391
|
// this same sync exactly once, on the tween's completion; the view cube's
|
|
1824
2392
|
// clicks are the only callers that do.
|
|
1825
|
-
if (projectionMode === "orthographic")
|
|
2393
|
+
if (projectionMode === "orthographic") {
|
|
2394
|
+
// A placed pose is not a rotation, so it re-arms rather than exits — as
|
|
2395
|
+
// long as it is still a face view. Orthographic only ever holds on one
|
|
2396
|
+
// (the projection is automatic), so a pose off every axis — an old
|
|
2397
|
+
// stored state, a host restoring a free-orbit pose — comes back in
|
|
2398
|
+
// perspective: from the frustum just synced to this distance, a lossless
|
|
2399
|
+
// round trip.
|
|
2400
|
+
syncOrthoToPerspectiveFraming();
|
|
2401
|
+
const dir = activeCamera.position.clone().sub(controls.target);
|
|
2402
|
+
if (isFaceAligned(dir.toArray())) orthoAxis = dir;
|
|
2403
|
+
else setProjection("perspective");
|
|
2404
|
+
}
|
|
1826
2405
|
}
|
|
1827
2406
|
function onCameraEnd(cb) { controls.addEventListener("end", cb); }
|
|
1828
2407
|
|
|
@@ -1957,6 +2536,7 @@ export function createViewer(container, part) {
|
|
|
1957
2536
|
controls.removeEventListener("start", onControlsStart);
|
|
1958
2537
|
cameraStartListeners.clear();
|
|
1959
2538
|
frameListeners.clear();
|
|
2539
|
+
assemblyListeners.clear();
|
|
1960
2540
|
themeListeners.clear();
|
|
1961
2541
|
projectionListeners.clear();
|
|
1962
2542
|
canonicalCaptureHidden.clear();
|
|
@@ -2020,7 +2600,9 @@ export function createViewer(container, part) {
|
|
|
2020
2600
|
captureCurrent,
|
|
2021
2601
|
renderMeshPayloads,
|
|
2022
2602
|
renderViews,
|
|
2603
|
+
renderStyleThumbnail,
|
|
2023
2604
|
onFrame,
|
|
2605
|
+
onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
|
|
2024
2606
|
tweenCameraTo,
|
|
2025
2607
|
cancelCameraTween,
|
|
2026
2608
|
orbitBy,
|
|
@@ -2034,7 +2616,7 @@ export function createViewer(container, part) {
|
|
|
2034
2616
|
setCameraState,
|
|
2035
2617
|
onCameraEnd,
|
|
2036
2618
|
// A GETTER, not a value: the active camera changes when the projection is
|
|
2037
|
-
//
|
|
2619
|
+
// swapped, and every consumer (measure/dim3-scene.js, selection/raycast.js,
|
|
2038
2620
|
// annotate/annotate-mode.js, measure/measure-mode.js) reads viewer.camera
|
|
2039
2621
|
// fresh at call time — so this is transparent to all of them.
|
|
2040
2622
|
get camera() { return activeCamera; },
|
|
@@ -2064,6 +2646,8 @@ export function createViewer(container, part) {
|
|
|
2064
2646
|
registerCanonicalCaptureHidden,
|
|
2065
2647
|
registerCaptureHidden,
|
|
2066
2648
|
onCutawayHandleHover: cutaway.onHandleHoverChange,
|
|
2649
|
+
// The section changed: on, off, or the plane moved. Listener gets no args.
|
|
2650
|
+
onCutawayChange: cutaway.onChange,
|
|
2067
2651
|
setRenderMode,
|
|
2068
2652
|
getRenderMode: () => renderMode,
|
|
2069
2653
|
onRenderModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
|
|
@@ -2078,6 +2662,8 @@ export function createViewer(container, part) {
|
|
|
2078
2662
|
isRealisticPending: () => realisticPending,
|
|
2079
2663
|
onEnvironmentChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
|
|
2080
2664
|
setPrintFrames,
|
|
2665
|
+
setPrintFrameSource,
|
|
2666
|
+
invalidatePrintFrames,
|
|
2081
2667
|
whenRealisticReady,
|
|
2082
2668
|
dispose,
|
|
2083
2669
|
};
|