partforge 0.122.0 → 0.124.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 +57 -31
- package/docs/KERNEL-CONTRACT.md +10 -0
- 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/geometry/blend-surfaces.js +256 -0
- package/src/framework/geometry/creased-normals.js +121 -7
- package/src/framework/geometry/manifold-backend.js +56 -15
- package/src/framework/geometry/mesh-fillet.js +55 -5
- package/src/framework/materials/assets/SOURCES.md +1 -1
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/environment.js +16 -4
- package/src/framework/materials/patterns.js +106 -7
- package/src/framework/materials/physical.js +9 -2
- package/src/framework/materials/presets.js +6 -3
- package/src/framework/materials/print-bed.js +17 -0
- package/src/framework/materials/uv.js +27 -8
- package/src/framework/mount.js +83 -36
- package/src/framework/projection.js +48 -1
- package/src/framework/view-state.js +4 -10
- package/src/framework/view-style-controls.js +287 -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 +526 -47
- 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,9 +548,16 @@ 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;
|
|
@@ -530,15 +573,44 @@ export function createViewer(container, part) {
|
|
|
530
573
|
|
|
531
574
|
// Anisotropic filtering: the ground disc and the wood grain are seen at grazing
|
|
532
575
|
// angles, where plain trilinear mip selection blurs them into smeared blocks.
|
|
576
|
+
//
|
|
577
|
+
// TextureLoader.load returns at once and fills `image` later, so a rig (or a
|
|
578
|
+
// physical material) is "ready" long before its maps are: a capture taken
|
|
579
|
+
// straight away draws the floor and the wood untextured. Each load therefore
|
|
580
|
+
// records a settle promise (resolved on load AND on error — a missing map is
|
|
581
|
+
// drawn without it, never an error), and whenTexturesSettled() waits for them.
|
|
582
|
+
const pendingTextures = new Set();
|
|
533
583
|
const loadTexture = (file) => {
|
|
534
584
|
let t = textureCache.get(file);
|
|
535
585
|
if (!t) {
|
|
536
|
-
|
|
586
|
+
let settle;
|
|
587
|
+
const settled = new Promise((resolve) => { settle = resolve; });
|
|
588
|
+
pendingTextures.add(settled);
|
|
589
|
+
settled.then(() => pendingTextures.delete(settled));
|
|
590
|
+
t = textureLoader.load(assetUrl(file), () => settle(), undefined, () => settle());
|
|
537
591
|
t.anisotropy = Math.min(8, renderer.capabilities?.getMaxAnisotropy?.() ?? 1);
|
|
538
592
|
textureCache.set(file, t);
|
|
539
593
|
}
|
|
540
594
|
return t;
|
|
541
595
|
};
|
|
596
|
+
// Every texture requested so far has loaded or failed. Loops, because waiting
|
|
597
|
+
// can overlap more requests (a material built by a later compile). Bounded:
|
|
598
|
+
// an image request that never answers must not hang a mode switch or a
|
|
599
|
+
// capture forever — past the cap it draws with whatever has arrived.
|
|
600
|
+
const TEXTURE_SETTLE_CAP_MS = 15000;
|
|
601
|
+
async function whenTexturesSettled() {
|
|
602
|
+
const deadline = Date.now() + TEXTURE_SETTLE_CAP_MS;
|
|
603
|
+
while (pendingTextures.size) {
|
|
604
|
+
const left = deadline - Date.now();
|
|
605
|
+
if (left <= 0) return;
|
|
606
|
+
let timer;
|
|
607
|
+
await Promise.race([
|
|
608
|
+
Promise.all([...pendingTextures]),
|
|
609
|
+
new Promise((resolve) => { timer = setTimeout(resolve, left); }),
|
|
610
|
+
]);
|
|
611
|
+
clearTimeout(timer);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
542
614
|
const loadHdr = (url) => new UltraHDRLoader().setDataType(THREE.HalfFloatType).loadAsync(url);
|
|
543
615
|
function rigFor(id) {
|
|
544
616
|
if (!rigCache.has(id)) {
|
|
@@ -552,6 +624,52 @@ export function createViewer(container, part) {
|
|
|
552
624
|
return rigCache.get(id);
|
|
553
625
|
}
|
|
554
626
|
|
|
627
|
+
// A rig loaded only to draw a capture (a thumbnail, or renderViews from CAD) is freed straight away: each holds
|
|
628
|
+
// its equirect (~16 MB of half-float) and a PMREM, and four of them resident
|
|
629
|
+
// on a phone is not acceptable. The live one (or one a switch is loading) stays
|
|
630
|
+
// — and only those: in CAD, the default environment is freed like any other.
|
|
631
|
+
// rig.dispose() also disposes the ground textures loadTexture cached — safe:
|
|
632
|
+
// each environment.js ground.texture/roughnessTexture/normalTexture is a
|
|
633
|
+
// distinct file (environments.js), so no two rigs ever share a Texture
|
|
634
|
+
// object, and Texture.dispose() only frees the GPU handle (via the
|
|
635
|
+
// renderer's 'dispose' listener) — it leaves texture.image and every field
|
|
636
|
+
// three's uploader reads untouched. loadTexture keeps returning that same
|
|
637
|
+
// (still-intact) Texture object out of textureCache, so a later rigFor for
|
|
638
|
+
// this id just re-uploads it on the next render, exactly like a first load.
|
|
639
|
+
//
|
|
640
|
+
// Concurrent captures of the same environment share one in-flight rig
|
|
641
|
+
// promise (rigFor's own cache), and a rig's envMap is a PMREM render-target
|
|
642
|
+
// texture that CANNOT re-upload once disposed — so releasing while a
|
|
643
|
+
// sibling call is still capturing with it would leave that capture's
|
|
644
|
+
// reflections black. EVERY async capture that awaits a rig must therefore
|
|
645
|
+
// borrow it through borrowRig and give it back through releaseRig in a
|
|
646
|
+
// `finally` (renderStyleThumbnail and renderViews both do). `p` is the
|
|
647
|
+
// exact promise borrowRig took from rigFor: `borrowedRigRefs` counts
|
|
648
|
+
// in-flight users of `id` (bumped synchronously in borrowRig, BEFORE any
|
|
649
|
+
// await, so an overlapping borrower is always already counted; decremented
|
|
650
|
+
// here), and this only deletes/
|
|
651
|
+
// disposes once that count reaches 0 AND `rigCache.get(id) === p` — the
|
|
652
|
+
// second check is what stops a call from freeing a *different* rig a later
|
|
653
|
+
// load (or the live environment) has since put in the cache for the same id.
|
|
654
|
+
function borrowRig(id) {
|
|
655
|
+
const p = rigFor(id);
|
|
656
|
+
borrowedRigRefs.set(id, (borrowedRigRefs.get(id) ?? 0) + 1);
|
|
657
|
+
return p;
|
|
658
|
+
}
|
|
659
|
+
function releaseRig(id, p) {
|
|
660
|
+
const refs = (borrowedRigRefs.get(id) ?? 0) - 1;
|
|
661
|
+
if (refs > 0) { borrowedRigRefs.set(id, refs); return; }
|
|
662
|
+
borrowedRigRefs.delete(id);
|
|
663
|
+
// Keep only a rig that is (or is about to be) on screen. In CAD the
|
|
664
|
+
// current environment's id alone is no reason: nothing is showing it, and
|
|
665
|
+
// a realistic switch reloads it (from the texture cache) when asked.
|
|
666
|
+
if (realisticRig?.id === id || (realisticPending && id === environmentId)) return;
|
|
667
|
+
if (rigCache.get(id) !== p) return;
|
|
668
|
+
rigCache.delete(id);
|
|
669
|
+
loadedRigs.delete(id);
|
|
670
|
+
p.then((r) => r.dispose(), () => {});
|
|
671
|
+
}
|
|
672
|
+
|
|
555
673
|
// three's Material.copy carries neither onBeforeCompile nor
|
|
556
674
|
// customProgramCacheKey, and it JSON-copies userData. For a patterned
|
|
557
675
|
// physical material (patterns.js) that means every clone the cutaway or a
|
|
@@ -580,9 +698,19 @@ export function createViewer(container, part) {
|
|
|
580
698
|
if (!m) {
|
|
581
699
|
m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
|
|
582
700
|
physicalMats.set(name, m);
|
|
701
|
+
syncGrain(name);
|
|
583
702
|
}
|
|
584
703
|
return m;
|
|
585
704
|
}
|
|
705
|
+
// Wood grain runs along the sub-part's longest axis (patterns.js). The
|
|
706
|
+
// uniforms are shared with every clone, so setting them once reaches the
|
|
707
|
+
// cutaway's and the fades' copies too. Called when the material is built and
|
|
708
|
+
// whenever new geometry lands (a regen can change which axis is longest).
|
|
709
|
+
function syncGrain(name) {
|
|
710
|
+
const m = physicalMats.get(name);
|
|
711
|
+
const geo = subCache[name];
|
|
712
|
+
if (m && geo) setGrainAxis(m, grainAxisFor(geo.boundingBox));
|
|
713
|
+
}
|
|
586
714
|
|
|
587
715
|
function publishMode(extra = {}) {
|
|
588
716
|
const evt = { mode: renderMode, busy: false, error: null, ...extra };
|
|
@@ -682,7 +810,7 @@ export function createViewer(container, part) {
|
|
|
682
810
|
// was still CAD, so setSubGeometry skipped the UVs. Idempotent.
|
|
683
811
|
for (const n of names) if (subCache[n] && physicalFor(n).userData.pfAnisotropic) ensureBoxUVs(subCache[n]);
|
|
684
812
|
setSubMaterials(physicalFor);
|
|
685
|
-
if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
|
|
813
|
+
if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group, ...(realisticRig.lights ?? []));
|
|
686
814
|
realisticRig = rig;
|
|
687
815
|
scene.environment = rig.envMap;
|
|
688
816
|
scene.background = rig.background;
|
|
@@ -690,13 +818,15 @@ export function createViewer(container, part) {
|
|
|
690
818
|
scene.backgroundIntensity = rig.backgroundIntensity ?? 1;
|
|
691
819
|
scene.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
|
|
692
820
|
scene.environmentRotation.set(0, rig.rotationY ?? 0, 0);
|
|
693
|
-
scene.add(rig.ground, rig.shadow.group);
|
|
821
|
+
scene.add(rig.ground, rig.shadow.group, ...(rig.lights ?? []));
|
|
694
822
|
for (const l of Object.values(liveLights)) l.visible = false;
|
|
695
823
|
grid.visible = false;
|
|
696
824
|
renderer.toneMapping = THREE.NeutralToneMapping;
|
|
697
825
|
renderer.toneMappingExposure = rig.exposure;
|
|
698
826
|
if (reground) placeGround({ force: !live });
|
|
699
827
|
if (live) applyPixelRatio("realistic");
|
|
828
|
+
// Hides the lines on every sub-part now that renderMode reads realistic.
|
|
829
|
+
refreshFeatureLines();
|
|
700
830
|
} catch (e) {
|
|
701
831
|
try { enterCad({ live }); } catch (rollback) { console.warn("partforge: rolling back to CAD failed", rollback); }
|
|
702
832
|
throw e;
|
|
@@ -714,7 +844,7 @@ export function createViewer(container, part) {
|
|
|
714
844
|
});
|
|
715
845
|
renderMode = "cad";
|
|
716
846
|
attempt(() => {
|
|
717
|
-
if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
|
|
847
|
+
if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group, ...(realisticRig.lights ?? []));
|
|
718
848
|
});
|
|
719
849
|
realisticRig = null;
|
|
720
850
|
shadowMovedAt = null;
|
|
@@ -730,6 +860,7 @@ export function createViewer(container, part) {
|
|
|
730
860
|
attempt(() => { grid.visible = true; });
|
|
731
861
|
for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
|
|
732
862
|
if (live) attempt(() => applyPixelRatio("cad"));
|
|
863
|
+
attempt(refreshFeatureLines);
|
|
733
864
|
if (firstError) throw firstError;
|
|
734
865
|
}
|
|
735
866
|
|
|
@@ -795,6 +926,10 @@ export function createViewer(container, part) {
|
|
|
795
926
|
if (isStale(token)) return renderMode;
|
|
796
927
|
await compileRealistic(rig);
|
|
797
928
|
if (isStale(token)) return renderMode;
|
|
929
|
+
// The ground's and materials' maps: without this the floor pops in a
|
|
930
|
+
// frame or two after the switch, untextured first.
|
|
931
|
+
await whenTexturesSettled();
|
|
932
|
+
if (isStale(token)) return renderMode;
|
|
798
933
|
enterRealistic(rig);
|
|
799
934
|
realisticPending = false;
|
|
800
935
|
publishMode();
|
|
@@ -849,10 +984,12 @@ export function createViewer(container, part) {
|
|
|
849
984
|
for (const p of rigCache.values()) p.then((r) => r.dispose(), () => {});
|
|
850
985
|
rigCache.clear();
|
|
851
986
|
loadedRigs.clear();
|
|
987
|
+
borrowedRigRefs.clear();
|
|
852
988
|
for (const m of physicalMats.values()) m.dispose();
|
|
853
989
|
physicalMats.clear();
|
|
854
990
|
for (const t of textureCache.values()) t.dispose();
|
|
855
991
|
textureCache.clear();
|
|
992
|
+
pendingTextures.clear();
|
|
856
993
|
pmrem?.dispose();
|
|
857
994
|
pmrem = null;
|
|
858
995
|
}
|
|
@@ -864,6 +1001,10 @@ export function createViewer(container, part) {
|
|
|
864
1001
|
const frameListeners = new Set();
|
|
865
1002
|
function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
|
|
866
1003
|
|
|
1004
|
+
// Fired at the end of every showAssembly, for hosts that need to react to
|
|
1005
|
+
// which sub-parts are visible (e.g. re-deriving a view style thumbnail).
|
|
1006
|
+
const assemblyListeners = new Set();
|
|
1007
|
+
|
|
867
1008
|
const camTween = createCameraTween();
|
|
868
1009
|
|
|
869
1010
|
// OrbitControls' damping is a rotational RESIDUAL, not a per-frame effect: it
|
|
@@ -912,7 +1053,23 @@ export function createViewer(container, part) {
|
|
|
912
1053
|
// the tween would re-zoom on every frame of a 0.6s cue (see setCameraState's
|
|
913
1054
|
// comment, which is where that reasoning is written down). Composed with the
|
|
914
1055
|
// caller's own onComplete rather than replacing it.
|
|
915
|
-
|
|
1056
|
+
//
|
|
1057
|
+
// `autoProjection` is the view cube's alone (its canvas clicks and its
|
|
1058
|
+
// per-face keyboard buttons): a tween that lands on a FACE view — straight
|
|
1059
|
+
// down a world axis — settles into orthographic at the end. Deliberately an
|
|
1060
|
+
// explicit option rather than inferred from the view name, because an
|
|
1061
|
+
// animation camera cue to "front" means "look from the front", not "switch
|
|
1062
|
+
// projection"; cues never pass it, so they never ENTER ortho.
|
|
1063
|
+
//
|
|
1064
|
+
// Leaving ortho is the same for every tween: one whose destination is not a
|
|
1065
|
+
// face view (an edge, a corner, iso) swaps back to perspective at the START.
|
|
1066
|
+
// At the start because the part is still seen head-on there, where the two
|
|
1067
|
+
// projections differ least, and the rotation that follows then reads as the
|
|
1068
|
+
// ordinary perspective orbit it is; swapping at the end instead would spin
|
|
1069
|
+
// the part flat and then pop the depth in at an oblique angle, where the
|
|
1070
|
+
// difference is largest. A tween between two faces while ortho stays ortho
|
|
1071
|
+
// the whole way — no projection change at all, rather than two in 0.6s.
|
|
1072
|
+
function tweenCameraTo(viewName, { duration = 0.6, onComplete, refit = false, autoProjection = false } = {}) {
|
|
916
1073
|
const box = getVisibleWorldBounds();
|
|
917
1074
|
if (!box || box.isEmpty()) { onComplete?.(); return; }
|
|
918
1075
|
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
@@ -920,11 +1077,24 @@ export function createViewer(container, part) {
|
|
|
920
1077
|
// radius = full max extent (not half), matching frameTo's framing distance so a
|
|
921
1078
|
// live camera cue doesn't land twice as close as the reframe button and crop the part.
|
|
922
1079
|
const pose = cameraPoseForView(viewName, { center, radius: Math.max(size.x, size.y, size.z) || 12 });
|
|
1080
|
+
const toDir = new THREE.Vector3().fromArray(pose.position).sub(new THREE.Vector3().fromArray(pose.target));
|
|
1081
|
+
const toFace = isFaceAligned(toDir.toArray());
|
|
1082
|
+
if (!toFace && projectionMode === "orthographic") setProjection("perspective");
|
|
923
1083
|
// The projection is read at COMPLETION, not now: a 0.6s tween is long
|
|
924
|
-
// enough for
|
|
1084
|
+
// enough for a host to have changed it under it.
|
|
925
1085
|
const finish = () => {
|
|
926
1086
|
restoreDamping();
|
|
927
|
-
if (
|
|
1087
|
+
if (autoProjection && toFace && projectionMode !== "orthographic") {
|
|
1088
|
+
// Settle into the face view. The tween fires onComplete from inside its
|
|
1089
|
+
// own update(), BEFORE the render loop writes the final pose onto the
|
|
1090
|
+
// camera, so put it there first — the render loop then writes the same
|
|
1091
|
+
// values onto the (now orthographic) camera. setProjection sizes the
|
|
1092
|
+
// frustum from the camera's distance, which is the pose's framing
|
|
1093
|
+
// distance, so this is also the refit, with zoom 1.
|
|
1094
|
+
activeCamera.position.fromArray(pose.position);
|
|
1095
|
+
controls.target.fromArray(pose.target);
|
|
1096
|
+
setProjection("orthographic");
|
|
1097
|
+
} else if (refit && projectionMode === "orthographic") {
|
|
928
1098
|
// The tween fires onComplete from inside its own update(), BEFORE the
|
|
929
1099
|
// render loop writes the final pose onto the camera — so the camera is
|
|
930
1100
|
// still a frame short of where it is going. Frame from the distance
|
|
@@ -932,8 +1102,14 @@ export function createViewer(container, part) {
|
|
|
932
1102
|
syncOrthoToPerspectiveFraming({
|
|
933
1103
|
distance: new THREE.Vector3().fromArray(pose.position)
|
|
934
1104
|
.distanceTo(new THREE.Vector3().fromArray(pose.target)),
|
|
1105
|
+
target: new THREE.Vector3().fromArray(pose.target),
|
|
1106
|
+
direction: toDir,
|
|
935
1107
|
});
|
|
936
1108
|
}
|
|
1109
|
+
// Still ortho at the end (a face-to-face tween, a cue onto a face, or the
|
|
1110
|
+
// switch just above): the face this tween landed on is the axis a
|
|
1111
|
+
// rotation now has to leave.
|
|
1112
|
+
if (projectionMode === "orthographic") orthoAxis = toDir.clone();
|
|
937
1113
|
onComplete?.();
|
|
938
1114
|
};
|
|
939
1115
|
suspendDamping();
|
|
@@ -997,20 +1173,189 @@ export function createViewer(container, part) {
|
|
|
997
1173
|
orthoCamera.updateProjectionMatrix();
|
|
998
1174
|
}
|
|
999
1175
|
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
//
|
|
1176
|
+
// Where a projection swap matches size. The two projections agree on the
|
|
1177
|
+
// size of things at exactly ONE depth — an orthographic view draws every
|
|
1178
|
+
// depth at one scale, a perspective one magnifies whatever is nearer — so the
|
|
1179
|
+
// swap has to choose it, and it chooses the surface the user is looking AT:
|
|
1180
|
+
// the first visible surface under the screen centre, found by a ray down the
|
|
1181
|
+
// view axis through the orbit target. It used to be the target's own depth
|
|
1182
|
+
// (the part's CENTRE, for a view cube face), while a face view shows the
|
|
1183
|
+
// part's FRONT: the swap back to perspective then magnified the surface on
|
|
1184
|
+
// screen by distance / (distance - depth), and since that distance is the
|
|
1185
|
+
// ortho zoom recovered as a dolly, zooming in made the jump grow (a planter
|
|
1186
|
+
// zoomed 2.27x jumped 22% wider and 53% taller).
|
|
1187
|
+
//
|
|
1188
|
+
// Returned as the signed distance of that surface from the target, along
|
|
1189
|
+
// `towardCamera` (target -> camera). Positive is in front of the target.
|
|
1190
|
+
// - What is visible is what is DRAWN: an opaque sub-part's front face
|
|
1191
|
+
// that survives the cutaway, or — with the cutaway on — the section cap
|
|
1192
|
+
// where the ray crosses the plane inside solid material (the cap is not a
|
|
1193
|
+
// sub-part mesh, so the ray finds it by parity rather than by a hit).
|
|
1194
|
+
// Ghosted sub-parts (a static display opacity below 1) are seen through.
|
|
1195
|
+
// - `maxDepth` is where a PERSPECTIVE camera sits: it cannot see anything
|
|
1196
|
+
// behind itself (an orthographic one can — its near plane may be
|
|
1197
|
+
// negative, see depth-range.js — so the exit swap searches the whole
|
|
1198
|
+
// line). A surface within MATCH_CLEARANCE of the camera is no surface
|
|
1199
|
+
// to match at: the target's depth is kept instead.
|
|
1200
|
+
// - Nothing on the ray (a hole, a gap between bodies, an edge-on sheet)
|
|
1201
|
+
// falls back to the front of the visible bounds: in a face view, the
|
|
1202
|
+
// nearest face of the part.
|
|
1203
|
+
// - Nothing at all shown: 0, the target's depth, as before.
|
|
1204
|
+
const _matchRaycaster = new THREE.Raycaster();
|
|
1205
|
+
const _matchDir = new THREE.Vector3();
|
|
1206
|
+
const _matchOrigin = new THREE.Vector3();
|
|
1207
|
+
const _matchCorner = new THREE.Vector3();
|
|
1208
|
+
const _matchSphere = new THREE.Sphere();
|
|
1209
|
+
const _matchNormal = new THREE.Vector3();
|
|
1210
|
+
const _matchNormalMat = new THREE.Matrix3();
|
|
1211
|
+
const _matchPlane = new THREE.Plane();
|
|
1212
|
+
const _equivDir = new THREE.Vector3();
|
|
1213
|
+
// Both sides of every triangle: the parity walk has to see the faces the ray
|
|
1214
|
+
// LEAVES a solid through, which a FrontSide material hides from a raycast.
|
|
1215
|
+
const _matchProxy = new THREE.Mesh(undefined, new THREE.MeshBasicMaterial({ side: THREE.DoubleSide }));
|
|
1216
|
+
_matchProxy.matrixAutoUpdate = false;
|
|
1217
|
+
const MATCH_CLEARANCE = 1e-3; // of the visible bounds' diagonal
|
|
1218
|
+
// The ray's candidate surfaces are remembered per RAY and SCENE: the target,
|
|
1219
|
+
// the direction, the cutaway's plane (null while off — so a toggle, drag,
|
|
1220
|
+
// flip or reset asks afresh), and a generation bumped by every geometry,
|
|
1221
|
+
// pose or visibility change (setSubGeometry, setSubPose, showAssembly,
|
|
1222
|
+
// hideAssembly) — a regenerated pocket or a hidden inner sub-part can move
|
|
1223
|
+
// the surface without moving the bounds. Swapping in and back out asks
|
|
1224
|
+
// about the same ray, but not bit-for-bit: controls.update() re-derives the
|
|
1225
|
+
// camera position from spherical coordinates on every swap, so the
|
|
1226
|
+
// direction comes back an ulp off — and a ray through a triangle edge (a
|
|
1227
|
+
// target at the centre of a symmetric part is exactly that) can hit on one
|
|
1228
|
+
// side of the ulp and miss on the other, which turned an untouched round
|
|
1229
|
+
// trip into a 25% reframe. What is remembered is the whole candidate list,
|
|
1230
|
+
// not one answer, so a query from a different camera position (`maxDepth`)
|
|
1231
|
+
// still gets its own, correct answer from it.
|
|
1232
|
+
let matchMemo = null; // { generation, target, dir, plane, candidates, fallback, clearance }
|
|
1233
|
+
const RAY_EPS = 1e-9;
|
|
1234
|
+
const samePlane = (a, b) => (a === null || b === null ? a === b
|
|
1235
|
+
: a.normal.distanceTo(b.normal) <= RAY_EPS && Math.abs(a.constant - b.constant) <= RAY_EPS * (1 + Math.abs(a.constant)));
|
|
1236
|
+
function sizeMatchDepth(target, towardCamera, { maxDepth = Infinity } = {}) {
|
|
1237
|
+
if (towardCamera.lengthSq() === 0) return 0;
|
|
1238
|
+
const box = getVisibleWorldBounds();
|
|
1239
|
+
if (box.isEmpty()) return 0;
|
|
1240
|
+
_matchDir.copy(towardCamera).normalize();
|
|
1241
|
+
const plane = cutaway.getPlane?.(_matchPlane) ?? null;
|
|
1242
|
+
const tol = RAY_EPS * (1 + box.max.distanceTo(box.min));
|
|
1243
|
+
let m = matchMemo;
|
|
1244
|
+
if (!(m && m.generation === matchGeneration && m.target.distanceTo(target) <= tol
|
|
1245
|
+
&& m.dir.dot(_matchDir) >= 1 - RAY_EPS && samePlane(m.plane, plane))) {
|
|
1246
|
+
m = matchMemo = {
|
|
1247
|
+
generation: matchGeneration,
|
|
1248
|
+
target: target.clone(),
|
|
1249
|
+
dir: _matchDir.clone(),
|
|
1250
|
+
plane: plane && plane.clone(),
|
|
1251
|
+
...matchCandidates(target, box, plane),
|
|
1252
|
+
};
|
|
1253
|
+
}
|
|
1254
|
+
// Candidates are nearest-the-camera first: the first one in front of the
|
|
1255
|
+
// camera, clear of it, is what the camera sees.
|
|
1256
|
+
for (const depth of m.candidates) {
|
|
1257
|
+
if (depth > maxDepth) continue; // behind a perspective camera
|
|
1258
|
+
return maxDepth - depth < m.clearance ? 0 : depth;
|
|
1259
|
+
}
|
|
1260
|
+
if (maxDepth - m.fallback < m.clearance) return 0;
|
|
1261
|
+
return m.fallback;
|
|
1262
|
+
}
|
|
1263
|
+
function matchCandidates(target, box, plane) {
|
|
1264
|
+
// Started outside everything shown rather than at the camera, so the ray
|
|
1265
|
+
// does not depend on where a perspective camera happens to be (an ortho
|
|
1266
|
+
// camera's position is a direction more than a place).
|
|
1267
|
+
box.getBoundingSphere(_matchSphere);
|
|
1268
|
+
const outside = target.distanceTo(_matchSphere.center) + _matchSphere.radius + 1;
|
|
1269
|
+
_matchOrigin.copy(target).addScaledVector(_matchDir, outside);
|
|
1270
|
+
const rayDir = _matchDir.clone().negate(); // camera -> target
|
|
1271
|
+
_matchRaycaster.set(_matchOrigin, rayDir);
|
|
1272
|
+
_matchRaycaster.far = 2 * outside;
|
|
1273
|
+
const hits = [];
|
|
1274
|
+
for (const n of names) {
|
|
1275
|
+
const mesh = subMesh[n];
|
|
1276
|
+
if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
|
|
1277
|
+
if ((part.parts[n].display?.opacity ?? 1) < 1) continue; // a ghost is seen through
|
|
1278
|
+
mesh.updateWorldMatrix(true, false);
|
|
1279
|
+
_matchProxy.geometry = mesh.geometry;
|
|
1280
|
+
_matchProxy.matrixWorld.copy(mesh.matrixWorld);
|
|
1281
|
+
_matchNormalMat.getNormalMatrix(mesh.matrixWorld);
|
|
1282
|
+
for (const hit of _matchRaycaster.intersectObject(_matchProxy, false)) {
|
|
1283
|
+
_matchNormal.copy(hit.face.normal).applyMatrix3(_matchNormalMat);
|
|
1284
|
+
hits.push({ t: hit.distance, point: hit.point, entering: _matchNormal.dot(rayDir) < 0 });
|
|
1285
|
+
}
|
|
1286
|
+
}
|
|
1287
|
+
_matchProxy.geometry = undefined;
|
|
1288
|
+
hits.sort((x, y) => x.t - y.t);
|
|
1289
|
+
// Where the ray crosses the cut plane, heading INTO the half that is kept:
|
|
1290
|
+
// a cap is drawn there if the crossing is inside solid material.
|
|
1291
|
+
let capT = null;
|
|
1292
|
+
if (plane) {
|
|
1293
|
+
const denom = plane.normal.dot(rayDir);
|
|
1294
|
+
if (denom > 0) {
|
|
1295
|
+
const t = -(plane.normal.dot(_matchOrigin) + plane.constant) / denom;
|
|
1296
|
+
if (t > 0) capT = t;
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
const candidates = [];
|
|
1300
|
+
let inside = 0;
|
|
1301
|
+
let capDone = capT === null;
|
|
1302
|
+
for (const hit of hits) {
|
|
1303
|
+
if (!capDone && hit.t >= capT) {
|
|
1304
|
+
capDone = true;
|
|
1305
|
+
if (inside > 0) candidates.push(outside - capT);
|
|
1306
|
+
}
|
|
1307
|
+
if (hit.entering && (cutaway.isPointVisible?.(hit.point) ?? true)) candidates.push(outside - hit.t);
|
|
1308
|
+
inside += hit.entering ? 1 : -1;
|
|
1309
|
+
}
|
|
1310
|
+
let fallback = -Infinity;
|
|
1311
|
+
for (let i = 0; i < 8; i++) {
|
|
1312
|
+
_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);
|
|
1313
|
+
fallback = Math.max(fallback, _matchCorner.sub(target).dot(_matchDir));
|
|
1314
|
+
}
|
|
1315
|
+
return {
|
|
1316
|
+
candidates,
|
|
1317
|
+
fallback: Number.isFinite(fallback) ? fallback : 0,
|
|
1318
|
+
clearance: MATCH_CLEARANCE * box.max.distanceTo(box.min),
|
|
1319
|
+
};
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// The perspective distance (target -> camera) that shows what the ortho
|
|
1323
|
+
// camera shows now, at the size-match surface above. OrbitControls zooms an
|
|
1324
|
+
// ortho camera through camera.zoom rather than by moving it, which is why
|
|
1325
|
+
// this reads the zoom. Falls back to the target's depth if the surface is so
|
|
1326
|
+
// far BEHIND the target that the camera would have to sit behind the target
|
|
1327
|
+
// to match it (a target panned out in front of everything).
|
|
1328
|
+
function orthoEquivalentDistance() {
|
|
1329
|
+
const halfH = (orthoCamera.top - orthoCamera.bottom) / 2 || 1;
|
|
1330
|
+
// `|| 1` on the zoom for the same reason captureCurrent guards it: a zero
|
|
1331
|
+
// would make this non-finite.
|
|
1332
|
+
const atSurface = perspectiveDistance({ halfH, zoom: orthoCamera.zoom || 1, fovDeg: camera.fov });
|
|
1333
|
+
const depth = sizeMatchDepth(controls.target, _equivDir.copy(orthoCamera.position).sub(controls.target));
|
|
1334
|
+
const distance = atSurface + depth;
|
|
1335
|
+
return distance > atSurface * 0.05 ? distance : atSurface;
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
// Re-derive the ortho frustum from the perspective camera's fov so that it
|
|
1339
|
+
// shows what a perspective camera at the CURRENT distance from the orbit
|
|
1340
|
+
// target shows at the size-match surface (sizeMatchDepth). Called on every
|
|
1341
|
+
// swap into ortho and after any reframe, which is what keeps the two
|
|
1342
|
+
// projections showing the same amount of part.
|
|
1004
1343
|
//
|
|
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
|
-
|
|
1344
|
+
// `distance` / `target` / `direction` override "current": tweenCameraTo's
|
|
1345
|
+
// refit runs from the tween's completion callback, which fires one frame
|
|
1346
|
+
// BEFORE the final pose reaches the camera, so it frames from the pose it
|
|
1347
|
+
// asked for rather than from where the camera happens to be at that instant.
|
|
1348
|
+
const _syncTarget = new THREE.Vector3();
|
|
1349
|
+
const _syncDir = new THREE.Vector3();
|
|
1350
|
+
function syncOrthoToPerspectiveFraming({ distance: atDistance, target, direction } = {}) {
|
|
1351
|
+
_syncTarget.copy(target ?? controls.target);
|
|
1352
|
+
if (direction) _syncDir.copy(direction);
|
|
1353
|
+
else _syncDir.copy(activeCamera.position).sub(_syncTarget);
|
|
1354
|
+
const distance = atDistance || _syncDir.length() || 1;
|
|
1355
|
+
const depth = sizeMatchDepth(_syncTarget, _syncDir, { maxDepth: distance });
|
|
1011
1356
|
applyOrthoFrustum(orthoFrustum({
|
|
1012
1357
|
fovDeg: camera.fov,
|
|
1013
|
-
distance,
|
|
1358
|
+
distance: distance - depth,
|
|
1014
1359
|
aspect: camera.aspect || 1,
|
|
1015
1360
|
}));
|
|
1016
1361
|
// The frustum now expresses the whole framing, so any dolly-by-zoom the user
|
|
@@ -1035,7 +1380,8 @@ export function createViewer(container, part) {
|
|
|
1035
1380
|
} else {
|
|
1036
1381
|
// Recover whatever dolly the user did while in ortho: OrbitControls
|
|
1037
1382
|
// 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
|
|
1383
|
+
// has to come back as a distance or the part jumps size — matched at the
|
|
1384
|
+
// surface the user is looking at (orthoEquivalentDistance).
|
|
1039
1385
|
//
|
|
1040
1386
|
// The bound exists because ortho zoom is UNBOUNDED and zooming a long way
|
|
1041
1387
|
// out costs nothing there (an ortho projection has no depth falloff) —
|
|
@@ -1046,17 +1392,15 @@ export function createViewer(container, part) {
|
|
|
1046
1392
|
// which is the camera that is still live and therefore the one
|
|
1047
1393
|
// updateDepthRange has been keeping current. `far * 0.9` alone would be
|
|
1048
1394
|
// too eager: frameTo frames at 2.6r + 6 MILLIMETRES, so an everyday 300mm
|
|
1049
|
-
// part sits at 786mm and a plain
|
|
1395
|
+
// part sits at 786mm and a plain swap would silently reframe it closer.
|
|
1050
1396
|
// 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;
|
|
1397
|
+
// untouched round trip (zoom === 1, where the entry and this exit match at
|
|
1398
|
+
// the same surface and are exact inverses) lossless for a part of ANY
|
|
1399
|
+
// size, and still never lets a degenerate zoom move the camera further
|
|
1400
|
+
// out than it already was.
|
|
1057
1401
|
const offset = from.position.clone().sub(controls.target);
|
|
1058
1402
|
const distance = Math.min(
|
|
1059
|
-
|
|
1403
|
+
orthoEquivalentDistance(),
|
|
1060
1404
|
Math.max(from.far * 0.9, offset.length()),
|
|
1061
1405
|
);
|
|
1062
1406
|
camera.position.copy(controls.target).addScaledVector(offset.normalize(), distance);
|
|
@@ -1080,12 +1424,16 @@ export function createViewer(container, part) {
|
|
|
1080
1424
|
// controls.update() ends in Object3D.lookAt, which does refresh matrixWorld
|
|
1081
1425
|
// — but it refreshes BEFORE writing the new quaternion, so a rotation
|
|
1082
1426
|
// applied inside that same update (damping momentum still decaying as the
|
|
1083
|
-
//
|
|
1427
|
+
// swap lands) leaves the rotation one frame behind. One matrix compose is
|
|
1084
1428
|
// cheaper than depending on that ordering. Placed after controls.update()
|
|
1085
1429
|
// for the same reason: it is the last writer of the pose.
|
|
1086
1430
|
to.updateMatrixWorld();
|
|
1087
1431
|
cutaway.setCamera(to);
|
|
1088
1432
|
projectionMode = next;
|
|
1433
|
+
// Ortho is armed on the direction it was entered on, whoever entered it (a
|
|
1434
|
+
// face click, a host's runtime.projection.set, a restore): the first
|
|
1435
|
+
// rotation off it returns to perspective the same way for all of them.
|
|
1436
|
+
orthoAxis = next === "orthographic" ? to.position.clone().sub(controls.target) : null;
|
|
1089
1437
|
for (const cb of [...projectionListeners]) cb(projectionMode);
|
|
1090
1438
|
return projectionMode;
|
|
1091
1439
|
}
|
|
@@ -1095,6 +1443,30 @@ export function createViewer(container, part) {
|
|
|
1095
1443
|
return () => projectionListeners.delete(cb);
|
|
1096
1444
|
}
|
|
1097
1445
|
|
|
1446
|
+
// Run every frame, after controls.update() and the tween have written the
|
|
1447
|
+
// pose: the first frame the view direction has turned off the ortho axis is a
|
|
1448
|
+
// ROTATION (a canvas drag, the cube's drag, a user grab that cancelled a tween
|
|
1449
|
+
// mid-turn), and it swaps back to perspective, size-preserving, from the
|
|
1450
|
+
// pose it has only just left. Direction rather than OrbitControls' "start"
|
|
1451
|
+
// event, because "start" also fires for pan and zoom, which keep the face
|
|
1452
|
+
// view. Here in the render loop rather than in a controls "change" listener
|
|
1453
|
+
// so setProjection's own controls.update() never runs nested inside one.
|
|
1454
|
+
// Skipped while a tween is in flight: it owns the camera, and decides the
|
|
1455
|
+
// projection itself (tweenCameraTo).
|
|
1456
|
+
// Allocation-free (it runs every frame while ortho): one scratch vector and
|
|
1457
|
+
// a dot product against the cosine of projection.js's epsilon — the same
|
|
1458
|
+
// test as hasLeftAxis, inlined.
|
|
1459
|
+
const _autoDir = new THREE.Vector3();
|
|
1460
|
+
const _axisUnit = new THREE.Vector3();
|
|
1461
|
+
function checkAutoProjection() {
|
|
1462
|
+
if (projectionMode !== "orthographic" || !orthoAxis || camTween.isActive()) return;
|
|
1463
|
+
_autoDir.copy(activeCamera.position).sub(controls.target);
|
|
1464
|
+
const len = _autoDir.length(), axisLen = orthoAxis.length();
|
|
1465
|
+
if (len === 0 || axisLen === 0) return;
|
|
1466
|
+
_axisUnit.copy(orthoAxis).divideScalar(axisLen);
|
|
1467
|
+
if (_autoDir.dot(_axisUnit) / len < FACE_ALIGN_COS) setProjection("perspective");
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1098
1470
|
// Fallback creasing for payloads with no kernel normals. Both backends now
|
|
1099
1471
|
// ship authoritative normals (Manifold: policy-aware crease pass; OCCT:
|
|
1100
1472
|
// analytic B-rep normals), so this path is last-ditch only — it must not be
|
|
@@ -1137,12 +1509,14 @@ export function createViewer(container, part) {
|
|
|
1137
1509
|
const subCache = Object.fromEntries(names.map((n) => [n, null]));
|
|
1138
1510
|
|
|
1139
1511
|
function setSubGeometry(name, payload) {
|
|
1512
|
+
matchGeneration++;
|
|
1140
1513
|
setSubPose(name, null); // fresh worker mesh is baked at current params — clear any fast-path pose
|
|
1141
1514
|
const prev = subCache[name];
|
|
1142
1515
|
const next = buildGeometry(payload);
|
|
1143
1516
|
// Brushed metal needs UVs for its tangent frame; CAD meshes carry none.
|
|
1144
1517
|
if (renderMode === "realistic" && physicalMats.get(name)?.userData.pfAnisotropic) ensureBoxUVs(next);
|
|
1145
1518
|
subCache[name] = next;
|
|
1519
|
+
syncGrain(name);
|
|
1146
1520
|
// Section helpers must stop referring to the old buffers before those
|
|
1147
1521
|
// buffers are released.
|
|
1148
1522
|
cutaway.updateGeometry(name, next);
|
|
@@ -1154,6 +1528,7 @@ export function createViewer(container, part) {
|
|
|
1154
1528
|
// three.js Matrix4 convention). Never affects exports or geometry — the worker
|
|
1155
1529
|
// owns real placement; this only re-poses the delivered mesh.
|
|
1156
1530
|
function setSubPose(name, mat16) {
|
|
1531
|
+
matchGeneration++;
|
|
1157
1532
|
for (const obj of [subMesh[name], subLines[name]]) {
|
|
1158
1533
|
if (!obj) continue;
|
|
1159
1534
|
obj.matrixAutoUpdate = false;
|
|
@@ -1173,6 +1548,7 @@ export function createViewer(container, part) {
|
|
|
1173
1548
|
// --- show / hide assembly -------------------------------------------------
|
|
1174
1549
|
const _box = new THREE.Box3();
|
|
1175
1550
|
const _posedBox = new THREE.Box3();
|
|
1551
|
+
const _frameDir = new THREE.Vector3();
|
|
1176
1552
|
|
|
1177
1553
|
// Recentre the assembly on the pivot and frame the camera to the named parts.
|
|
1178
1554
|
// Cached bounding boxes are in the delivered mesh's own frame, so any fast-path
|
|
@@ -1193,7 +1569,16 @@ export function createViewer(container, part) {
|
|
|
1193
1569
|
floorY = -size.z / 2;
|
|
1194
1570
|
grid.position.y = floorY;
|
|
1195
1571
|
const r = Math.max(size.x, size.y, size.z) || 12;
|
|
1196
|
-
|
|
1572
|
+
// Keep the VIEW DIRECTION (target -> camera), not the camera's direction
|
|
1573
|
+
// from the origin: after a pan the target is off the origin, and scaling
|
|
1574
|
+
// the position about the origin would turn the view — which, in an ortho
|
|
1575
|
+
// face view, reads as a rotation and drops to perspective at an off-axis
|
|
1576
|
+
// angle on the next frame. Falls back to the old origin-relative direction
|
|
1577
|
+
// for a degenerate offset (camera on its target).
|
|
1578
|
+
_frameDir.copy(activeCamera.position).sub(controls.target);
|
|
1579
|
+
if (_frameDir.lengthSq() === 0) _frameDir.copy(activeCamera.position);
|
|
1580
|
+
if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
|
|
1581
|
+
activeCamera.position.copy(_frameDir.normalize().multiplyScalar(r * 2.6 + 6));
|
|
1197
1582
|
controls.target.set(0, 0, 0);
|
|
1198
1583
|
// Framing under ortho is a frustum, not a distance — without this the
|
|
1199
1584
|
// reframe button moves the camera and nothing visibly changes.
|
|
@@ -1204,6 +1589,7 @@ export function createViewer(container, part) {
|
|
|
1204
1589
|
// frame the camera to them — done only on the initial show and on view (tab)
|
|
1205
1590
|
// changes, NOT on regeneration, so a user's zoom/orbit survives editing params.
|
|
1206
1591
|
function showAssembly(visibleNames, { frame = false } = {}) {
|
|
1592
|
+
matchGeneration++;
|
|
1207
1593
|
lastShown = [...visibleNames];
|
|
1208
1594
|
for (const name of names) {
|
|
1209
1595
|
if (visibleNames.includes(name)) {
|
|
@@ -1218,6 +1604,9 @@ export function createViewer(container, part) {
|
|
|
1218
1604
|
if (frame) frameTo(visibleNames);
|
|
1219
1605
|
cutaway.setVisible(effectiveVisible());
|
|
1220
1606
|
placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
|
|
1607
|
+
for (const cb of [...assemblyListeners]) {
|
|
1608
|
+
try { cb(); } catch (e) { console.warn("partforge: assembly listener failed", e); }
|
|
1609
|
+
}
|
|
1221
1610
|
}
|
|
1222
1611
|
|
|
1223
1612
|
// Re-frame whatever is currently visible (the reframe button).
|
|
@@ -1273,6 +1662,7 @@ export function createViewer(container, part) {
|
|
|
1273
1662
|
}
|
|
1274
1663
|
|
|
1275
1664
|
function hideAssembly() {
|
|
1665
|
+
matchGeneration++;
|
|
1276
1666
|
lastShown = [];
|
|
1277
1667
|
for (const m of Object.values(subMesh)) m.visible = false;
|
|
1278
1668
|
for (const l of Object.values(subLines)) l.visible = false;
|
|
@@ -1491,7 +1881,8 @@ export function createViewer(container, part) {
|
|
|
1491
1881
|
// renders, and the editor's on-screen mode must never change what the agent
|
|
1492
1882
|
// sees. A realistic live view lends its scene to CAD for the synchronous
|
|
1493
1883
|
// capture and gets it back exactly (captureIn). renderViews is the one way
|
|
1494
|
-
// to ask for realistic canonical views.
|
|
1884
|
+
// to ask for realistic canonical views. Feature lines are CAD-only, so
|
|
1885
|
+
// borrowing CAD for this capture draws them for free.
|
|
1495
1886
|
function captureCanonicalViews(viewNames) {
|
|
1496
1887
|
if (disposed) return [];
|
|
1497
1888
|
const box = getVisibleWorldBounds();
|
|
@@ -1598,20 +1989,75 @@ export function createViewer(container, part) {
|
|
|
1598
1989
|
}
|
|
1599
1990
|
}
|
|
1600
1991
|
|
|
1992
|
+
// One small render of the CURRENT framing in any style, for the view style
|
|
1993
|
+
// popover. The live view is put back exactly (same contract as captureIn:
|
|
1994
|
+
// no publish, nothing persisted), whether it is CAD, or realistic in this
|
|
1995
|
+
// or another environment. The borrowed style brings its own feature-lines
|
|
1996
|
+
// visibility with it for free, since it's decided by render mode alone.
|
|
1997
|
+
async function renderStyleThumbnail(style, { size = 256 } = {}) {
|
|
1998
|
+
if (disposed) return null;
|
|
1999
|
+
const box = getVisibleWorldBounds();
|
|
2000
|
+
if (!box || box.isEmpty()) return null;
|
|
2001
|
+
const capture = () => currentFramingInCurrentLook({ size, quality: 0.8 });
|
|
2002
|
+
if (style === "cad") return captureIn("cad", null, capture);
|
|
2003
|
+
const id = resolveEnvironmentId(style).id;
|
|
2004
|
+
// Borrowed BEFORE awaiting (see releaseRig).
|
|
2005
|
+
const p = borrowRig(id);
|
|
2006
|
+
try {
|
|
2007
|
+
const rig = await p;
|
|
2008
|
+
if (disposed) return null;
|
|
2009
|
+
await compileRealistic(rig, { forCapture: true });
|
|
2010
|
+
if (disposed) return null;
|
|
2011
|
+
await whenTexturesSettled();
|
|
2012
|
+
if (disposed) return null;
|
|
2013
|
+
if (renderMode !== "realistic" || realisticRig === rig) return captureIn("realistic", rig, capture);
|
|
2014
|
+
// Realistic in another environment: borrow this rig, then put the live one back.
|
|
2015
|
+
const liveRig = realisticRig, movedAt = shadowMovedAt;
|
|
2016
|
+
try {
|
|
2017
|
+
enterRealistic(rig, { live: false });
|
|
2018
|
+
return capture();
|
|
2019
|
+
} finally {
|
|
2020
|
+
try {
|
|
2021
|
+
enterRealistic(liveRig, { live: false, reground: false });
|
|
2022
|
+
shadowMovedAt = movedAt;
|
|
2023
|
+
} catch (e) {
|
|
2024
|
+
// enterRealistic already rolled the scene back to CAD: make it the live mode.
|
|
2025
|
+
console.warn("partforge: restoring the realistic view after a thumbnail failed", e);
|
|
2026
|
+
try { applyPixelRatio("cad"); } catch { /* the view is CAD either way */ }
|
|
2027
|
+
publishMode({ error: "couldn't load realistic view" });
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
} finally {
|
|
2031
|
+
releaseRig(id, p);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
|
|
1601
2035
|
// Agent-facing canonical renders in a chosen appearance, whatever the live
|
|
1602
2036
|
// view shows. CAD is exactly captureCanonicalViews (which is always CAD).
|
|
1603
2037
|
// Realistic waits for the current environment's rig and the capture's
|
|
1604
2038
|
// shader programs, then borrows the realistic look for the synchronous
|
|
1605
2039
|
// capture only if the live view is not already realistic. A rig that fails
|
|
1606
|
-
// to load rejects rather than silently returning CAD images.
|
|
2040
|
+
// to load rejects rather than silently returning CAD images. Feature lines
|
|
2041
|
+
// are CAD-only, so borrowing the realistic look draws without them for free.
|
|
1607
2042
|
async function renderViews(viewNames, { renderMode: want = "cad" } = {}) {
|
|
1608
2043
|
if (disposed) return [];
|
|
1609
2044
|
if (want !== "realistic") return captureCanonicalViews(viewNames);
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
2045
|
+
// Borrowed like a thumbnail's rig, so a thumbnail of the same environment
|
|
2046
|
+
// finishing first cannot dispose it mid-capture (and, in CAD, it is freed
|
|
2047
|
+
// once this is done — it is not on screen).
|
|
2048
|
+
const id = environmentId;
|
|
2049
|
+
const p = borrowRig(id);
|
|
2050
|
+
try {
|
|
2051
|
+
const rig = await p;
|
|
2052
|
+
if (disposed) return [];
|
|
2053
|
+
await compileRealistic(rig, { forCapture: true });
|
|
2054
|
+
if (disposed) return [];
|
|
2055
|
+
await whenTexturesSettled();
|
|
2056
|
+
if (disposed) return [];
|
|
2057
|
+
return captureIn("realistic", rig, () => canonicalViewsInCurrentLook(viewNames));
|
|
2058
|
+
} finally {
|
|
2059
|
+
releaseRig(id, p);
|
|
2060
|
+
}
|
|
1615
2061
|
}
|
|
1616
2062
|
|
|
1617
2063
|
// Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
|
|
@@ -1676,7 +2122,7 @@ export function createViewer(container, part) {
|
|
|
1676
2122
|
try {
|
|
1677
2123
|
// fov comes from the PERSPECTIVE camera, deliberately, not from whichever
|
|
1678
2124
|
// camera is live: thumbnails are canonical captures and stay perspective
|
|
1679
|
-
//
|
|
2125
|
+
// whichever projection is live. cameraPoseForView's distance
|
|
1680
2126
|
// is tuned to this fov, so a narrower one would crop long, thin parts.
|
|
1681
2127
|
return renderOffscreen(
|
|
1682
2128
|
pose,
|
|
@@ -1711,6 +2157,7 @@ export function createViewer(container, part) {
|
|
|
1711
2157
|
activeCamera.position.fromArray(tw.position);
|
|
1712
2158
|
controls.target.fromArray(tw.target);
|
|
1713
2159
|
}
|
|
2160
|
+
checkAutoProjection();
|
|
1714
2161
|
// Per-listener guard, because three re-arms requestAnimationFrame only AFTER
|
|
1715
2162
|
// this callback returns (WebGLAnimation.onAnimationFrame): a listener that
|
|
1716
2163
|
// throws would stop the rAF chain outright and freeze the viewer for good, not
|
|
@@ -1797,10 +2244,28 @@ export function createViewer(container, part) {
|
|
|
1797
2244
|
}
|
|
1798
2245
|
|
|
1799
2246
|
// --- camera state (read/write for persistence; mount.js owns storage) -------
|
|
2247
|
+
// Under ortho, `pos` is the PERSPECTIVE-EQUIVALENT position: same view
|
|
2248
|
+
// direction, at the distance whose perspective frame shows what the ortho
|
|
2249
|
+
// frustum shows now (halfH / zoom — setProjection's own conversion). An
|
|
2250
|
+
// ortho zoom never moves the camera, so the raw position would carry none of
|
|
2251
|
+
// it, and setCameraState — which re-derives the frustum from the distance at
|
|
2252
|
+
// zoom 1 — would bring a remount back at the unzoomed size. Every reader of
|
|
2253
|
+
// this state (viewerState's carry-over, the reload-persisted camera) means
|
|
2254
|
+
// "what the user was looking at", so they all want the equivalent. The live
|
|
2255
|
+
// ortho camera's own position is viewer.camera.position.
|
|
1800
2256
|
function getCameraState() {
|
|
2257
|
+
const t = controls.target;
|
|
2258
|
+
if (projectionMode === "orthographic") {
|
|
2259
|
+
const offset = activeCamera.position.clone().sub(t);
|
|
2260
|
+
if (offset.lengthSq() > 0) {
|
|
2261
|
+
const d = orthoEquivalentDistance();
|
|
2262
|
+
const p = offset.normalize().multiplyScalar(d).add(t);
|
|
2263
|
+
return { pos: [p.x, p.y, p.z], target: [t.x, t.y, t.z] };
|
|
2264
|
+
}
|
|
2265
|
+
}
|
|
1801
2266
|
return {
|
|
1802
2267
|
pos: [activeCamera.position.x, activeCamera.position.y, activeCamera.position.z],
|
|
1803
|
-
target: [
|
|
2268
|
+
target: [t.x, t.y, t.z],
|
|
1804
2269
|
};
|
|
1805
2270
|
}
|
|
1806
2271
|
function setCameraState({ pos, target }) {
|
|
@@ -1808,7 +2273,7 @@ export function createViewer(container, part) {
|
|
|
1808
2273
|
controls.target.set(target[0], target[1], target[2]);
|
|
1809
2274
|
controls.update();
|
|
1810
2275
|
// A saved pose carries an implied FRAMING, so the ortho frustum has to be
|
|
1811
|
-
// re-derived from the restored distance. Without it, a
|
|
2276
|
+
// re-derived from the restored distance. Without it, a remount in ortho comes
|
|
1812
2277
|
// back at the wrong zoom: the projection is restored during mount setup,
|
|
1813
2278
|
// while the camera is restored much later (showView, on the first accepted
|
|
1814
2279
|
// build), so the frustum would stay sized for wherever the camera happened
|
|
@@ -1822,7 +2287,18 @@ export function createViewer(container, part) {
|
|
|
1822
2287
|
// that really does mean "refit" opts in with `{ refit: true }`, which runs
|
|
1823
2288
|
// this same sync exactly once, on the tween's completion; the view cube's
|
|
1824
2289
|
// clicks are the only callers that do.
|
|
1825
|
-
if (projectionMode === "orthographic")
|
|
2290
|
+
if (projectionMode === "orthographic") {
|
|
2291
|
+
// A placed pose is not a rotation, so it re-arms rather than exits — as
|
|
2292
|
+
// long as it is still a face view. Orthographic only ever holds on one
|
|
2293
|
+
// (the projection is automatic), so a pose off every axis — an old
|
|
2294
|
+
// stored state, a host restoring a free-orbit pose — comes back in
|
|
2295
|
+
// perspective: from the frustum just synced to this distance, a lossless
|
|
2296
|
+
// round trip.
|
|
2297
|
+
syncOrthoToPerspectiveFraming();
|
|
2298
|
+
const dir = activeCamera.position.clone().sub(controls.target);
|
|
2299
|
+
if (isFaceAligned(dir.toArray())) orthoAxis = dir;
|
|
2300
|
+
else setProjection("perspective");
|
|
2301
|
+
}
|
|
1826
2302
|
}
|
|
1827
2303
|
function onCameraEnd(cb) { controls.addEventListener("end", cb); }
|
|
1828
2304
|
|
|
@@ -1957,6 +2433,7 @@ export function createViewer(container, part) {
|
|
|
1957
2433
|
controls.removeEventListener("start", onControlsStart);
|
|
1958
2434
|
cameraStartListeners.clear();
|
|
1959
2435
|
frameListeners.clear();
|
|
2436
|
+
assemblyListeners.clear();
|
|
1960
2437
|
themeListeners.clear();
|
|
1961
2438
|
projectionListeners.clear();
|
|
1962
2439
|
canonicalCaptureHidden.clear();
|
|
@@ -2020,7 +2497,9 @@ export function createViewer(container, part) {
|
|
|
2020
2497
|
captureCurrent,
|
|
2021
2498
|
renderMeshPayloads,
|
|
2022
2499
|
renderViews,
|
|
2500
|
+
renderStyleThumbnail,
|
|
2023
2501
|
onFrame,
|
|
2502
|
+
onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
|
|
2024
2503
|
tweenCameraTo,
|
|
2025
2504
|
cancelCameraTween,
|
|
2026
2505
|
orbitBy,
|
|
@@ -2034,7 +2513,7 @@ export function createViewer(container, part) {
|
|
|
2034
2513
|
setCameraState,
|
|
2035
2514
|
onCameraEnd,
|
|
2036
2515
|
// A GETTER, not a value: the active camera changes when the projection is
|
|
2037
|
-
//
|
|
2516
|
+
// swapped, and every consumer (measure/dim3-scene.js, selection/raycast.js,
|
|
2038
2517
|
// annotate/annotate-mode.js, measure/measure-mode.js) reads viewer.camera
|
|
2039
2518
|
// fresh at call time — so this is transparent to all of them.
|
|
2040
2519
|
get camera() { return activeCamera; },
|