partforge 0.121.1 → 0.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/AUTHORING-PARTS.md +164 -3
- package/docs/KERNEL-CONTRACT.md +10 -0
- package/package.json +3 -1
- package/src/app-materials.js +23 -0
- package/src/framework/app.css +16 -1
- package/src/framework/cutaway.js +12 -0
- 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/geometry/threemf.js +32 -3
- package/src/framework/jobs.js +2 -1
- package/src/framework/lint/index.js +2 -1
- package/src/framework/lint/rules-materials.js +56 -0
- package/src/framework/materials/assets/SOURCES.md +90 -0
- package/src/framework/materials/assets/env-outdoor.jpg +0 -0
- package/src/framework/materials/assets/env-print-bed.jpg +0 -0
- package/src/framework/materials/assets/env-studio.jpg +0 -0
- package/src/framework/materials/assets/env-workshop.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-concrete.jpg +0 -0
- package/src/framework/materials/assets/ground-maple-color.jpg +0 -0
- package/src/framework/materials/assets/ground-maple-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-paper-normal.jpg +0 -0
- package/src/framework/materials/assets/ground-paper-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-paper.jpg +0 -0
- package/src/framework/materials/assets/ground-pei-rough.jpg +0 -0
- package/src/framework/materials/assets/ground-pei.jpg +0 -0
- package/src/framework/materials/assets/pattern-carbon.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-oak-rough.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-color.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-normal.jpg +0 -0
- package/src/framework/materials/assets/pattern-walnut-rough.jpg +0 -0
- package/src/framework/materials/assets.js +39 -0
- package/src/framework/materials/contact-shadow.js +155 -0
- package/src/framework/materials/environment.js +157 -0
- package/src/framework/materials/environments.js +82 -0
- package/src/framework/materials/patterns.js +243 -0
- package/src/framework/materials/physical.js +88 -0
- package/src/framework/materials/presets.js +89 -0
- package/src/framework/materials/print-bed.js +169 -0
- package/src/framework/materials/print-frame.js +18 -0
- package/src/framework/materials/resolve.js +117 -0
- package/src/framework/materials/tonemap-readback.js +36 -0
- package/src/framework/materials/uv.js +42 -0
- package/src/framework/mount.js +121 -4
- package/src/framework/realistic-controls.js +78 -0
- package/src/framework/selection/feature-highlight.js +3 -0
- package/src/framework/view-state.js +27 -1
- package/src/framework/viewer.js +623 -34
- package/src/materials-worker.js +3 -0
- package/src/parts/bracket.js +2 -1
- package/src/parts/hinged-box.js +3 -1
- package/src/parts/material-swatches.js +40 -0
- package/src/parts/planter.js +2 -1
- package/src/parts/propeller.js +2 -1
- package/src/parts/scott-label.js +3 -2
- package/src/parts/screw.js +2 -1
- package/types/index.d.ts +48 -3
- package/types/part.d.ts +15 -2
package/src/framework/viewer.js
CHANGED
|
@@ -4,6 +4,13 @@ import { toCreasedNormals } from "three/addons/utils/BufferGeometryUtils.js";
|
|
|
4
4
|
import { LineSegments2 } from "three/addons/lines/LineSegments2.js";
|
|
5
5
|
import { LineSegmentsGeometry } from "three/addons/lines/LineSegmentsGeometry.js";
|
|
6
6
|
import { LineMaterial } from "three/addons/lines/LineMaterial.js";
|
|
7
|
+
import { UltraHDRLoader } from "three/addons/loaders/UltraHDRLoader.js";
|
|
8
|
+
import { buildCadMaterial, buildPhysicalMaterial } from "./materials/physical.js";
|
|
9
|
+
import { ensureBoxUVs } from "./materials/uv.js";
|
|
10
|
+
import { loadEnvironmentRig } from "./materials/environment.js";
|
|
11
|
+
import { assetUrl } from "./materials/assets.js";
|
|
12
|
+
import { resolveEnvironmentId } from "./materials/resolve.js";
|
|
13
|
+
import { neutralToneMapToSrgb8 } from "./materials/tonemap-readback.js";
|
|
7
14
|
import { createCutaway } from "./cutaway.js";
|
|
8
15
|
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
9
16
|
import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
|
|
@@ -41,6 +48,31 @@ export function srgbEncodeInPlace(data) {
|
|
|
41
48
|
return data;
|
|
42
49
|
}
|
|
43
50
|
|
|
51
|
+
// Readback (a realistic capture's target) → linear floats: half-float bits,
|
|
52
|
+
// or linear bytes from the 8-bit fallback target.
|
|
53
|
+
function readbackToLinear(buf) {
|
|
54
|
+
const out = new Float32Array(buf.length);
|
|
55
|
+
if (buf instanceof Uint16Array) for (let i = 0; i < buf.length; i++) out[i] = THREE.DataUtils.fromHalfFloat(buf[i]);
|
|
56
|
+
else for (let i = 0; i < buf.length; i++) out[i] = buf[i] / 255;
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Can a realistic capture render HDR into a half-float target and read it
|
|
61
|
+
// back? Both halves fail SILENTLY otherwise: three's readRenderTargetPixels
|
|
62
|
+
// logs and returns when the type is not readable, and gl.readPixels with
|
|
63
|
+
// RGBA/HALF_FLOAT raises an unseen INVALID_OPERATION unless that is the
|
|
64
|
+
// implementation's read type — either way the buffer stays zero and the
|
|
65
|
+
// capture is a valid, all-black JPEG. `readable` is three's
|
|
66
|
+
// capabilities.textureTypeReadable(HalfFloatType) (needs a colour-buffer
|
|
67
|
+
// float extension, which is also what makes the target renderable);
|
|
68
|
+
// `readType` is the implementation read type for a bound, complete
|
|
69
|
+
// half-float framebuffer, or null when no GL context was there to ask (a
|
|
70
|
+
// failed probe passes something that is not `halfFloat`).
|
|
71
|
+
export function halfFloatCaptureSupported({ readable, readType = null, halfFloat = null }) {
|
|
72
|
+
if (readable !== true) return false;
|
|
73
|
+
return readType === null || readType === halfFloat;
|
|
74
|
+
}
|
|
75
|
+
|
|
44
76
|
// Render a set of canonical views without disturbing the live camera/canvas.
|
|
45
77
|
// `renderer.renderOffscreen(pose)` does the GL work (temp camera → offscreen
|
|
46
78
|
// target → readback → JPEG data URL); injected so this is unit-testable without
|
|
@@ -219,16 +251,11 @@ export function createViewer(container, part) {
|
|
|
219
251
|
const partsGroup = new THREE.Group();
|
|
220
252
|
pivot.add(partsGroup);
|
|
221
253
|
|
|
222
|
-
// Per-sub-part material:
|
|
223
|
-
// `display
|
|
224
|
-
//
|
|
254
|
+
// Per-sub-part CAD material: the shared default unless the sub-part declares
|
|
255
|
+
// appearance in `display` (colour, opacity, or a library material flattened
|
|
256
|
+
// for the CAD view — materials/resolve.js cadAppearance).
|
|
225
257
|
function materialFor(name) {
|
|
226
|
-
|
|
227
|
-
if (!disp || (disp.color == null && disp.opacity == null)) return material;
|
|
228
|
-
const m = material.clone();
|
|
229
|
-
if (disp.color != null) m.color = new THREE.Color(disp.color);
|
|
230
|
-
if (disp.opacity != null && disp.opacity < 1) { m.transparent = true; m.opacity = disp.opacity; m.depthWrite = false; }
|
|
231
|
-
return m;
|
|
258
|
+
return buildCadMaterial(part.parts[name].display, material);
|
|
232
259
|
}
|
|
233
260
|
|
|
234
261
|
const subMesh = Object.fromEntries(
|
|
@@ -259,10 +286,20 @@ export function createViewer(container, part) {
|
|
|
259
286
|
// half of "fade a part in, then animate it into place".
|
|
260
287
|
const animOpacity = new Map(); // name -> value in [0, 1)
|
|
261
288
|
const baseMats = Object.fromEntries(names.map((n) => [n, subMesh[n].material]));
|
|
289
|
+
// baseMats is the CURRENT source material per sub-part, which realistic mode
|
|
290
|
+
// re-points at a physical material; cadMats keeps the CAD ones for the way
|
|
291
|
+
// back, and is what dispose() frees.
|
|
292
|
+
const cadMats = { ...baseMats };
|
|
262
293
|
const fadeMats = new Map(); // name -> lazily cloned MeshStandardMaterial
|
|
263
294
|
const fadeLineMats = new Map(); // name -> lazily cloned LineMaterial
|
|
264
295
|
const fadeUnregisters = new Map(); // fade material -> its cutaway unregister fn
|
|
265
296
|
let lastShown = []; // names last passed to showAssembly
|
|
297
|
+
// The render mode's LOOK, read by applySubOpacity (feature lines are CAD-only)
|
|
298
|
+
// and the depth range (the realistic ground disc). Declared here, ahead of
|
|
299
|
+
// everything that reads it; the mode itself lives in the realistic-mode block.
|
|
300
|
+
let renderMode = "cad";
|
|
301
|
+
let realisticRig = null;
|
|
302
|
+
let shadowMovedAt = null; // performance.now() of a pose change the contact shadow has not caught up with
|
|
266
303
|
|
|
267
304
|
const effectiveVisible = () => lastShown.filter((n) => (animOpacity.get(n) ?? 1) > 0);
|
|
268
305
|
|
|
@@ -324,7 +361,7 @@ export function createViewer(container, part) {
|
|
|
324
361
|
// every-regen showAssembly path stays a no-op for un-faded sub-parts.
|
|
325
362
|
if (hadFade) cutaway.resyncSubpart(name);
|
|
326
363
|
mesh.visible = shown;
|
|
327
|
-
lines.visible = shown;
|
|
364
|
+
lines.visible = shown && renderMode === "cad";
|
|
328
365
|
return;
|
|
329
366
|
}
|
|
330
367
|
if (v <= 0) {
|
|
@@ -340,7 +377,7 @@ export function createViewer(container, part) {
|
|
|
340
377
|
flm.opacity = v;
|
|
341
378
|
lines.material = flm;
|
|
342
379
|
mesh.visible = shown;
|
|
343
|
-
lines.visible = shown;
|
|
380
|
+
lines.visible = shown && renderMode === "cad";
|
|
344
381
|
}
|
|
345
382
|
|
|
346
383
|
function setSubPartOpacity(name, value) {
|
|
@@ -350,7 +387,10 @@ export function createViewer(container, part) {
|
|
|
350
387
|
else animOpacity.set(name, Math.max(0, value));
|
|
351
388
|
applySubOpacity(name);
|
|
352
389
|
const isZero = (animOpacity.get(name) ?? 1) <= 0;
|
|
353
|
-
if (wasZero !== isZero)
|
|
390
|
+
if (wasZero !== isZero) {
|
|
391
|
+
cutaway.setVisible(effectiveVisible());
|
|
392
|
+
if (realisticRig) shadowMovedAt = performance.now(); // a caster came or went
|
|
393
|
+
}
|
|
354
394
|
}
|
|
355
395
|
|
|
356
396
|
function clearSubPartOpacities() {
|
|
@@ -396,6 +436,7 @@ export function createViewer(container, part) {
|
|
|
396
436
|
const _depthCenter = new THREE.Vector3();
|
|
397
437
|
const _depthSize = new THREE.Vector3();
|
|
398
438
|
const _gridCorner = new THREE.Vector3();
|
|
439
|
+
const _groundBounds = new THREE.Box3();
|
|
399
440
|
function sceneDepthBounds({ withGrid } = {}) {
|
|
400
441
|
// getVisibleWorldBounds returns a SHARED Box3 that the cutaway also reads,
|
|
401
442
|
// so copy before touching it.
|
|
@@ -405,6 +446,10 @@ export function createViewer(container, part) {
|
|
|
405
446
|
_depthBounds.expandByPoint(_gridCorner.set(-half, floorY, -half));
|
|
406
447
|
_depthBounds.expandByPoint(_gridCorner.set(half, floorY, half));
|
|
407
448
|
}
|
|
449
|
+
// The realistic ground disc is several part radii across, so it is the
|
|
450
|
+
// bigger half of the answer the way the grid is in CAD: without it the far
|
|
451
|
+
// plane cuts the disc's back edge off.
|
|
452
|
+
if (realisticRig) _depthBounds.union(_groundBounds.setFromObject(realisticRig.ground));
|
|
408
453
|
if (_depthBounds.isEmpty()) return null;
|
|
409
454
|
return {
|
|
410
455
|
center: _depthBounds.getCenter(_depthCenter).toArray(),
|
|
@@ -447,6 +492,371 @@ export function createViewer(container, part) {
|
|
|
447
492
|
cutaway.setSubpart(name, subMesh[name], subLines[name]);
|
|
448
493
|
}
|
|
449
494
|
|
|
495
|
+
// --- realistic mode ---------------------------------------------------------
|
|
496
|
+
// "cad" is the drafting view (flat lights, grid, feature lines); "realistic"
|
|
497
|
+
// swaps every sub-part onto a physical material lit by an environment rig,
|
|
498
|
+
// with a ground and contact shadow brought to the part — which never moves.
|
|
499
|
+
//
|
|
500
|
+
// Materials change through ONE seam, rebaseSubMaterial: baseMats is what the
|
|
501
|
+
// fade clones and the cutaway's clipped clones are derived from, so the swap
|
|
502
|
+
// re-points that and lets both re-derive, rather than writing mesh.material
|
|
503
|
+
// (which the cutaway and the fades own) directly.
|
|
504
|
+
//
|
|
505
|
+
// Everything a load can fail at (the rig, the physical materials, the shader
|
|
506
|
+
// compile) happens BEFORE the live scene is touched, and the swap itself is
|
|
507
|
+
// one synchronous block that rolls back to CAD if it throws — so a failure at
|
|
508
|
+
// any point leaves a whole CAD (or the previous realistic) view, never a
|
|
509
|
+
// half-swapped one. Rigs are cached for the viewer's life and freed only in
|
|
510
|
+
// dispose(): scene.environment/background may still point into one.
|
|
511
|
+
let environmentId = resolveEnvironmentId(part.meta?.environment).id;
|
|
512
|
+
let modeToken = 0; // bumped by every request; a stale completion does nothing
|
|
513
|
+
let realisticPending = false; // a realistic request is in flight (setEnvironment joins it)
|
|
514
|
+
let environmentChosen = false; // set by setEnvironment; false while seeded from meta/default
|
|
515
|
+
const physicalMats = new Map(); // name -> MeshPhysicalMaterial (environment-independent, cached)
|
|
516
|
+
const rigCache = new Map(); // environment id -> Promise<Rig>
|
|
517
|
+
const loadedRigs = new Map(); // environment id -> Rig, once its load has landed (synchronous captures)
|
|
518
|
+
const textureCache = new Map(); // asset file name -> Texture
|
|
519
|
+
const textureLoader = new THREE.TextureLoader();
|
|
520
|
+
let pmrem = null;
|
|
521
|
+
let printFrames = {};
|
|
522
|
+
const modeListeners = new Set();
|
|
523
|
+
const envListeners = new Set();
|
|
524
|
+
const SHADOW_LOWRES_MS = 100; // at most one low-res shadow per this, while a part moves
|
|
525
|
+
const SHADOW_SETTLE_MS = 200; // …and one full-res render this long after the last move
|
|
526
|
+
let shadowLowResAt = -Infinity;
|
|
527
|
+
const isCoarsePointer = () => {
|
|
528
|
+
try { return typeof matchMedia === "function" && matchMedia("(pointer: coarse)").matches; } catch { return false; }
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
// Anisotropic filtering: the ground disc and the wood grain are seen at grazing
|
|
532
|
+
// angles, where plain trilinear mip selection blurs them into smeared blocks.
|
|
533
|
+
const loadTexture = (file) => {
|
|
534
|
+
let t = textureCache.get(file);
|
|
535
|
+
if (!t) {
|
|
536
|
+
t = textureLoader.load(assetUrl(file));
|
|
537
|
+
t.anisotropy = Math.min(8, renderer.capabilities?.getMaxAnisotropy?.() ?? 1);
|
|
538
|
+
textureCache.set(file, t);
|
|
539
|
+
}
|
|
540
|
+
return t;
|
|
541
|
+
};
|
|
542
|
+
const loadHdr = (url) => new UltraHDRLoader().setDataType(THREE.HalfFloatType).loadAsync(url);
|
|
543
|
+
function rigFor(id) {
|
|
544
|
+
if (!rigCache.has(id)) {
|
|
545
|
+
pmrem ??= new THREE.PMREMGenerator(renderer);
|
|
546
|
+
const p = loadEnvironmentRig(renderer, id, { loadHdr, loadTexture, pmrem });
|
|
547
|
+
// A failure is retryable, not memoized — but only drop THIS attempt.
|
|
548
|
+
p.then((rig) => { if (rigCache.get(id) === p) loadedRigs.set(id, rig); },
|
|
549
|
+
() => { if (rigCache.get(id) === p) rigCache.delete(id); });
|
|
550
|
+
rigCache.set(id, p);
|
|
551
|
+
}
|
|
552
|
+
return rigCache.get(id);
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
// three's Material.copy carries neither onBeforeCompile nor
|
|
556
|
+
// customProgramCacheKey, and it JSON-copies userData. For a patterned
|
|
557
|
+
// physical material (patterns.js) that means every clone the cutaway or a
|
|
558
|
+
// fade makes silently drops the pattern — and serializes the pattern texture's
|
|
559
|
+
// image through a canvas to do it. So a patterned material's clone shares the
|
|
560
|
+
// hooks and the uniforms object (one print frame drives them all) instead.
|
|
561
|
+
function cloneKeepsPattern(m) {
|
|
562
|
+
if (!Object.hasOwn(m, "onBeforeCompile")) return m;
|
|
563
|
+
m.clone = function clone() {
|
|
564
|
+
const uniforms = this.userData.patternUniforms;
|
|
565
|
+
delete this.userData.patternUniforms;
|
|
566
|
+
let c;
|
|
567
|
+
try { c = THREE.MeshPhysicalMaterial.prototype.clone.call(this); } finally {
|
|
568
|
+
if (uniforms) this.userData.patternUniforms = uniforms;
|
|
569
|
+
}
|
|
570
|
+
if (uniforms) c.userData.patternUniforms = uniforms;
|
|
571
|
+
c.onBeforeCompile = this.onBeforeCompile;
|
|
572
|
+
c.customProgramCacheKey = this.customProgramCacheKey;
|
|
573
|
+
c.clone = clone;
|
|
574
|
+
return c;
|
|
575
|
+
};
|
|
576
|
+
return m;
|
|
577
|
+
}
|
|
578
|
+
function physicalFor(name) {
|
|
579
|
+
let m = physicalMats.get(name);
|
|
580
|
+
if (!m) {
|
|
581
|
+
m = cloneKeepsPattern(buildPhysicalMaterial(part.parts[name].display, { printFrame: printFrames[name], loadTexture }));
|
|
582
|
+
physicalMats.set(name, m);
|
|
583
|
+
}
|
|
584
|
+
return m;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function publishMode(extra = {}) {
|
|
588
|
+
const evt = { mode: renderMode, busy: false, error: null, ...extra };
|
|
589
|
+
for (const cb of [...modeListeners]) {
|
|
590
|
+
try { cb(evt); } catch (e) { console.warn("partforge: render-mode listener failed", e); }
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Point one sub-part at a new source material. The fade clones were cloned
|
|
595
|
+
// from the old one, so they go (applySubOpacity re-clones on demand); the
|
|
596
|
+
// cutaway re-derives its clipped clone and reassigns mesh.material for its
|
|
597
|
+
// current state; then any live fade is re-asserted on top.
|
|
598
|
+
function rebaseSubMaterial(name, mat) {
|
|
599
|
+
for (const map of [fadeMats, fadeLineMats]) {
|
|
600
|
+
const clone = map.get(name);
|
|
601
|
+
if (!clone) continue;
|
|
602
|
+
fadeUnregisters.get(clone)?.();
|
|
603
|
+
fadeUnregisters.delete(clone);
|
|
604
|
+
clone.dispose();
|
|
605
|
+
map.delete(name);
|
|
606
|
+
}
|
|
607
|
+
baseMats[name] = mat;
|
|
608
|
+
cutaway.refreshSubpartMaterial(name, mat);
|
|
609
|
+
applySubOpacity(name);
|
|
610
|
+
}
|
|
611
|
+
function setSubMaterials(matFor) {
|
|
612
|
+
for (const n of names) {
|
|
613
|
+
const m = matFor(n);
|
|
614
|
+
if (baseMats[n] !== m) rebaseSubMaterial(n, m);
|
|
615
|
+
else applySubOpacity(n); // same material, but the lines follow the mode
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// Ghosts (a static display opacity below 1) cast no shadow.
|
|
620
|
+
const castersNow = () => names
|
|
621
|
+
.filter((n) => subMesh[n].visible && (part.parts[n].display?.opacity ?? 1) >= 1)
|
|
622
|
+
.map((n) => subMesh[n]);
|
|
623
|
+
// `force` renders even while parked: a capture is offscreen work a parked
|
|
624
|
+
// viewer still does, and it must not bake a stale (or absent) shadow.
|
|
625
|
+
function renderShadow(opts, { force = false } = {}) {
|
|
626
|
+
if (!realisticRig || (!active && !force)) return;
|
|
627
|
+
try {
|
|
628
|
+
if (opts) realisticRig.shadow.render(scene, castersNow(), opts);
|
|
629
|
+
else realisticRig.shadow.render(scene, castersNow());
|
|
630
|
+
} catch (e) {
|
|
631
|
+
console.warn("partforge: contact shadow failed", e);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
// The ground comes to the part: it sits at the bottom of what is visible and
|
|
635
|
+
// is sized from it. Only on showAssembly (and on entering the mode) — an
|
|
636
|
+
// animated pose change moves the shadow, never the floor.
|
|
637
|
+
function placeGround({ force = false } = {}) {
|
|
638
|
+
if (!realisticRig) return;
|
|
639
|
+
const b = getVisibleWorldBounds(); // shared Box3: read it out before anything else runs
|
|
640
|
+
if (b.isEmpty()) return;
|
|
641
|
+
const center = b.getCenter(new THREE.Vector3());
|
|
642
|
+
const size = b.getSize(new THREE.Vector3());
|
|
643
|
+
realisticRig.setGround({
|
|
644
|
+
y: b.min.y, centerX: center.x, centerZ: center.z, radius: size.length() / 2, footprintMm: Math.max(size.x, size.z),
|
|
645
|
+
});
|
|
646
|
+
shadowMovedAt = null; // this render is the full-resolution one
|
|
647
|
+
renderShadow(undefined, { force });
|
|
648
|
+
}
|
|
649
|
+
// Called once per rendered frame: low-res while a part is moving, one full
|
|
650
|
+
// render once it has settled, nothing while it is still.
|
|
651
|
+
function updateMovingShadow() {
|
|
652
|
+
if (!realisticRig || shadowMovedAt == null) return;
|
|
653
|
+
const t = performance.now();
|
|
654
|
+
if (t - shadowMovedAt >= SHADOW_SETTLE_MS) {
|
|
655
|
+
shadowMovedAt = null;
|
|
656
|
+
renderShadow();
|
|
657
|
+
} else if (t - shadowLowResAt >= SHADOW_LOWRES_MS) {
|
|
658
|
+
shadowLowResAt = t;
|
|
659
|
+
renderShadow({ lowRes: true });
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const livePixelRatio = (mode) =>
|
|
664
|
+
Math.min(devicePixelRatio, mode === "realistic" && isCoarsePointer() ? 1.5 : 2);
|
|
665
|
+
function applyPixelRatio(mode) {
|
|
666
|
+
const want = livePixelRatio(mode);
|
|
667
|
+
if (renderer.getPixelRatio() === want) return;
|
|
668
|
+
renderer.setPixelRatio(want);
|
|
669
|
+
resize();
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
// The two synchronous halves of a mode change. `live: false` is for a
|
|
673
|
+
// capture that borrows a look for one synchronous render (it leaves the
|
|
674
|
+
// canvas's pixel ratio alone, and renders the shadow even while parked);
|
|
675
|
+
// nothing here publishes — setRenderMode does. `reground: false` puts a rig
|
|
676
|
+
// back exactly where it was (after a CAD capture borrowed the scene): the
|
|
677
|
+
// ground only moves on showAssembly, never because a capture happened.
|
|
678
|
+
function enterRealistic(rig, { live = true, reground = true } = {}) {
|
|
679
|
+
try {
|
|
680
|
+
renderMode = "realistic";
|
|
681
|
+
// A regen can land between the proxy compile and here, while the mode
|
|
682
|
+
// was still CAD, so setSubGeometry skipped the UVs. Idempotent.
|
|
683
|
+
for (const n of names) if (subCache[n] && physicalFor(n).userData.pfAnisotropic) ensureBoxUVs(subCache[n]);
|
|
684
|
+
setSubMaterials(physicalFor);
|
|
685
|
+
if (realisticRig && realisticRig !== rig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
|
|
686
|
+
realisticRig = rig;
|
|
687
|
+
scene.environment = rig.envMap;
|
|
688
|
+
scene.background = rig.background;
|
|
689
|
+
scene.backgroundBlurriness = rig.backgroundBlurriness;
|
|
690
|
+
scene.backgroundIntensity = rig.backgroundIntensity ?? 1;
|
|
691
|
+
scene.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
|
|
692
|
+
scene.environmentRotation.set(0, rig.rotationY ?? 0, 0);
|
|
693
|
+
scene.add(rig.ground, rig.shadow.group);
|
|
694
|
+
for (const l of Object.values(liveLights)) l.visible = false;
|
|
695
|
+
grid.visible = false;
|
|
696
|
+
renderer.toneMapping = THREE.NeutralToneMapping;
|
|
697
|
+
renderer.toneMappingExposure = rig.exposure;
|
|
698
|
+
if (reground) placeGround({ force: !live });
|
|
699
|
+
if (live) applyPixelRatio("realistic");
|
|
700
|
+
} catch (e) {
|
|
701
|
+
try { enterCad({ live }); } catch (rollback) { console.warn("partforge: rolling back to CAD failed", rollback); }
|
|
702
|
+
throw e;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
// Every step is attempted even if an earlier one throws (the first error is
|
|
706
|
+
// rethrown at the end): this is the rollback path too, and a half-restored
|
|
707
|
+
// CAD view is exactly what it exists to prevent. Tone mapping goes first.
|
|
708
|
+
function enterCad({ live = true } = {}) {
|
|
709
|
+
let firstError = null;
|
|
710
|
+
const attempt = (fn) => { try { fn(); } catch (e) { firstError ??= e; } };
|
|
711
|
+
attempt(() => {
|
|
712
|
+
renderer.toneMapping = THREE.NoToneMapping;
|
|
713
|
+
renderer.toneMappingExposure = 1;
|
|
714
|
+
});
|
|
715
|
+
renderMode = "cad";
|
|
716
|
+
attempt(() => {
|
|
717
|
+
if (realisticRig) scene.remove(realisticRig.ground, realisticRig.shadow.group);
|
|
718
|
+
});
|
|
719
|
+
realisticRig = null;
|
|
720
|
+
shadowMovedAt = null;
|
|
721
|
+
attempt(() => {
|
|
722
|
+
scene.environment = null;
|
|
723
|
+
scene.background = new THREE.Color(THEME[currentTheme].bg);
|
|
724
|
+
scene.backgroundBlurriness = 0;
|
|
725
|
+
scene.backgroundIntensity = 1;
|
|
726
|
+
scene.backgroundRotation.set(0, 0, 0);
|
|
727
|
+
scene.environmentRotation.set(0, 0, 0);
|
|
728
|
+
});
|
|
729
|
+
attempt(() => { for (const l of Object.values(liveLights)) l.visible = true; });
|
|
730
|
+
attempt(() => { grid.visible = true; });
|
|
731
|
+
for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
|
|
732
|
+
if (live) attempt(() => applyPixelRatio("cad"));
|
|
733
|
+
if (firstError) throw firstError;
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
const isStale = (token) => disposed || token !== modeToken;
|
|
737
|
+
|
|
738
|
+
// Compile every built sub-part's physical program against the rig before the
|
|
739
|
+
// look is swapped in, so the swap does not stall on shader compilation.
|
|
740
|
+
// Three keys a program on tone mapping and output colour space, and both
|
|
741
|
+
// differ with a render target bound, so the canvas variant compiles under
|
|
742
|
+
// the realistic tone mapping and a capture's variant with a render
|
|
743
|
+
// target bound — each only for the synchronous compile() inside
|
|
744
|
+
// compileAsync, so the view on screen is untouched while it waits.
|
|
745
|
+
function compileRealistic(rig, { forCapture = false } = {}) {
|
|
746
|
+
const proxy = new THREE.Scene();
|
|
747
|
+
proxy.environment = rig.envMap;
|
|
748
|
+
for (const n of names) {
|
|
749
|
+
const m = physicalFor(n);
|
|
750
|
+
const geo = subCache[n];
|
|
751
|
+
if (!geo) continue; // not built yet: it compiles when it is first shown
|
|
752
|
+
if (m.userData.pfAnisotropic) ensureBoxUVs(geo);
|
|
753
|
+
proxy.add(new THREE.Mesh(geo, m));
|
|
754
|
+
}
|
|
755
|
+
const toneMappingBefore = renderer.toneMapping;
|
|
756
|
+
// Any bound target gives the capture's key (NoToneMapping, linear output);
|
|
757
|
+
// an 8-bit one is always renderable.
|
|
758
|
+
const probe = forCapture ? new THREE.WebGLRenderTarget(1, 1) : null;
|
|
759
|
+
if (probe) renderer.setRenderTarget(probe);
|
|
760
|
+
else renderer.toneMapping = THREE.NeutralToneMapping;
|
|
761
|
+
let compiling;
|
|
762
|
+
try { compiling = renderer.compileAsync?.(proxy, activeCamera); } finally {
|
|
763
|
+
renderer.toneMapping = toneMappingBefore;
|
|
764
|
+
if (probe) renderer.setRenderTarget(null);
|
|
765
|
+
}
|
|
766
|
+
return Promise.resolve(compiling).finally(() => probe?.dispose());
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
// Resolves to the mode actually in effect. A later setRenderMode or
|
|
770
|
+
// setEnvironment supersedes this one; a superseded completion changes
|
|
771
|
+
// nothing (its rig and materials are cached, so there is nothing to undo).
|
|
772
|
+
async function setRenderMode(next) {
|
|
773
|
+
if (disposed) return renderMode;
|
|
774
|
+
const want = next === "realistic" ? "realistic" : "cad";
|
|
775
|
+
const token = ++modeToken;
|
|
776
|
+
if (want === "cad") {
|
|
777
|
+
realisticPending = false;
|
|
778
|
+
if (renderMode !== "cad") {
|
|
779
|
+
try { enterCad(); } catch (e) { console.warn("partforge: leaving the realistic view failed", e); }
|
|
780
|
+
}
|
|
781
|
+
publishMode();
|
|
782
|
+
return renderMode;
|
|
783
|
+
}
|
|
784
|
+
const envId = environmentId;
|
|
785
|
+
if (renderMode === "realistic" && realisticRig?.id === envId) {
|
|
786
|
+
realisticPending = false;
|
|
787
|
+
publishMode();
|
|
788
|
+
return renderMode;
|
|
789
|
+
}
|
|
790
|
+
const wasRealistic = renderMode === "realistic";
|
|
791
|
+
realisticPending = true;
|
|
792
|
+
publishMode({ busy: true });
|
|
793
|
+
try {
|
|
794
|
+
const rig = await rigFor(envId);
|
|
795
|
+
if (isStale(token)) return renderMode;
|
|
796
|
+
await compileRealistic(rig);
|
|
797
|
+
if (isStale(token)) return renderMode;
|
|
798
|
+
enterRealistic(rig);
|
|
799
|
+
realisticPending = false;
|
|
800
|
+
publishMode();
|
|
801
|
+
} catch (e) {
|
|
802
|
+
if (isStale(token)) return renderMode;
|
|
803
|
+
realisticPending = false;
|
|
804
|
+
console.warn("partforge: the realistic view failed to load", e);
|
|
805
|
+
// Still realistic means the previous rig is still what's on screen: the
|
|
806
|
+
// environment the switch asked for never landed, so the id (and anyone
|
|
807
|
+
// showing it) goes back to the one actually lit.
|
|
808
|
+
if (renderMode === "realistic" && realisticRig && realisticRig.id !== environmentId) {
|
|
809
|
+
announceEnvironment(realisticRig.id);
|
|
810
|
+
}
|
|
811
|
+
publishMode({ error: wasRealistic && renderMode === "realistic" ? "couldn't load that environment" : "couldn't load realistic view" });
|
|
812
|
+
}
|
|
813
|
+
return renderMode;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function announceEnvironment(id) {
|
|
817
|
+
environmentId = id;
|
|
818
|
+
for (const cb of [...envListeners]) {
|
|
819
|
+
try { cb(id); } catch (e) { console.warn("partforge: environment listener failed", e); }
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
// The id is recorded (and announced) at once; in realistic mode — or on the
|
|
824
|
+
// way into it — the new rig then loads and replaces the current one. If that
|
|
825
|
+
// load fails while realistic, setRenderMode reverts the id to the rig still
|
|
826
|
+
// shown and announces that too; in CAD nothing loads, so the id just stands.
|
|
827
|
+
async function setEnvironment(id) {
|
|
828
|
+
const next = resolveEnvironmentId(id).id;
|
|
829
|
+
environmentChosen = true;
|
|
830
|
+
if (next !== environmentId) announceEnvironment(next);
|
|
831
|
+
if (renderMode === "realistic" || realisticPending) await setRenderMode("realistic");
|
|
832
|
+
return environmentId;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
// Per-sub-part print frames (display → export) for the layer-line pattern.
|
|
836
|
+
function setPrintFrames(frames) {
|
|
837
|
+
printFrames = frames ?? {};
|
|
838
|
+
const identity = new THREE.Matrix4().toArray();
|
|
839
|
+
for (const [n, m] of physicalMats) m.userData.patternUniforms?.pfPrintFrame.value.fromArray(printFrames[n] ?? identity);
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Resolves once the current environment's rig is loaded (captures wait on it).
|
|
843
|
+
const whenRealisticReady = () => rigFor(environmentId).then(() => {});
|
|
844
|
+
|
|
845
|
+
function disposeRealistic() {
|
|
846
|
+
modeToken++;
|
|
847
|
+
modeListeners.clear();
|
|
848
|
+
envListeners.clear();
|
|
849
|
+
for (const p of rigCache.values()) p.then((r) => r.dispose(), () => {});
|
|
850
|
+
rigCache.clear();
|
|
851
|
+
loadedRigs.clear();
|
|
852
|
+
for (const m of physicalMats.values()) m.dispose();
|
|
853
|
+
physicalMats.clear();
|
|
854
|
+
for (const t of textureCache.values()) t.dispose();
|
|
855
|
+
textureCache.clear();
|
|
856
|
+
pmrem?.dispose();
|
|
857
|
+
pmrem = null;
|
|
858
|
+
}
|
|
859
|
+
|
|
450
860
|
// --- animation hooks --------------------------------------------------------
|
|
451
861
|
// Frame listeners get dt (seconds, clamped so a background-tab return doesn't
|
|
452
862
|
// fast-forward playback) inside the render loop — so a parked viewer
|
|
@@ -730,6 +1140,8 @@ export function createViewer(container, part) {
|
|
|
730
1140
|
setSubPose(name, null); // fresh worker mesh is baked at current params — clear any fast-path pose
|
|
731
1141
|
const prev = subCache[name];
|
|
732
1142
|
const next = buildGeometry(payload);
|
|
1143
|
+
// Brushed metal needs UVs for its tangent frame; CAD meshes carry none.
|
|
1144
|
+
if (renderMode === "realistic" && physicalMats.get(name)?.userData.pfAnisotropic) ensureBoxUVs(next);
|
|
733
1145
|
subCache[name] = next;
|
|
734
1146
|
// Section helpers must stop referring to the old buffers before those
|
|
735
1147
|
// buffers are released.
|
|
@@ -749,6 +1161,9 @@ export function createViewer(container, part) {
|
|
|
749
1161
|
else obj.matrix.identity();
|
|
750
1162
|
obj.matrixWorldNeedsUpdate = true;
|
|
751
1163
|
}
|
|
1164
|
+
// The contact shadow follows on the render loop's throttle (see
|
|
1165
|
+
// updateMovingShadow); the ground itself stays put until showAssembly.
|
|
1166
|
+
if (realisticRig) shadowMovedAt = performance.now();
|
|
752
1167
|
}
|
|
753
1168
|
|
|
754
1169
|
// Cache queries for the app's regenerate loop (so it never reaches into subCache).
|
|
@@ -802,6 +1217,7 @@ export function createViewer(container, part) {
|
|
|
802
1217
|
}
|
|
803
1218
|
if (frame) frameTo(visibleNames);
|
|
804
1219
|
cutaway.setVisible(effectiveVisible());
|
|
1220
|
+
placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
|
|
805
1221
|
}
|
|
806
1222
|
|
|
807
1223
|
// Re-frame whatever is currently visible (the reframe button).
|
|
@@ -840,10 +1256,13 @@ export function createViewer(container, part) {
|
|
|
840
1256
|
// Swap the scene background, grid, and edge-line colors for the given theme.
|
|
841
1257
|
function setTheme(mode) {
|
|
842
1258
|
const t = THEME[mode] ?? THEME.dark;
|
|
843
|
-
|
|
1259
|
+
// Realistic mode's backdrop belongs to the environment and its grid is off;
|
|
1260
|
+
// enterCad puts the theme's background back on the way out.
|
|
1261
|
+
if (renderMode === "cad") scene.background = new THREE.Color(t.bg);
|
|
844
1262
|
scene.remove(grid);
|
|
845
1263
|
grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, t.grid[0], t.grid[1]);
|
|
846
1264
|
grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
|
|
1265
|
+
grid.visible = renderMode === "cad";
|
|
847
1266
|
scene.add(grid);
|
|
848
1267
|
lineMaterial.color.set(t.line);
|
|
849
1268
|
for (const m of fadeLineMats.values()) m.color.set(t.line); // clones follow the theme
|
|
@@ -908,6 +1327,45 @@ export function createViewer(container, part) {
|
|
|
908
1327
|
// the mask silently no-ops and every cap floods its whole plane with hatch —
|
|
909
1328
|
// no error, live view unaffected, wrong only in the capture.
|
|
910
1329
|
const RT_OPTIONS = { samples: 4, stencilBuffer: true };
|
|
1330
|
+
// Decided once per viewer, on the first realistic capture (see
|
|
1331
|
+
// halfFloatCaptureSupported). Two 1×1 probes, because a capture needs two
|
|
1332
|
+
// things of half-float: RENDERING into the exact target a capture uses (4×
|
|
1333
|
+
// MSAA with a stencil — a GPU can support single-sampled half-float and
|
|
1334
|
+
// still refuse a multisampled one, which would come back as a black capture),
|
|
1335
|
+
// and READING BACK, which three does from the resolved single-sampled
|
|
1336
|
+
// framebuffer, so the read format is asked of a plain half-float target.
|
|
1337
|
+
let _hdrReadback = null;
|
|
1338
|
+
function hdrCaptureReadback() {
|
|
1339
|
+
if (_hdrReadback !== null) return _hdrReadback;
|
|
1340
|
+
const readable = renderer.capabilities?.textureTypeReadable?.(THREE.HalfFloatType) === true;
|
|
1341
|
+
let readType = null, halfFloat = null;
|
|
1342
|
+
const gl = renderer.getContext?.();
|
|
1343
|
+
if (readable && typeof gl?.getParameter === "function") {
|
|
1344
|
+
halfFloat = gl.HALF_FLOAT;
|
|
1345
|
+
const complete = () => gl.checkFramebufferStatus(gl.FRAMEBUFFER) === gl.FRAMEBUFFER_COMPLETE;
|
|
1346
|
+
const renderProbe = new THREE.WebGLRenderTarget(1, 1, { ...RT_OPTIONS, type: THREE.HalfFloatType });
|
|
1347
|
+
const readProbe = new THREE.WebGLRenderTarget(1, 1, { type: THREE.HalfFloatType });
|
|
1348
|
+
try {
|
|
1349
|
+
renderer.setRenderTarget(renderProbe);
|
|
1350
|
+
const renderable = complete();
|
|
1351
|
+
renderer.setRenderTarget(readProbe);
|
|
1352
|
+
readType = renderable && complete() && gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_FORMAT) === gl.RGBA
|
|
1353
|
+
? gl.getParameter(gl.IMPLEMENTATION_COLOR_READ_TYPE)
|
|
1354
|
+
: -1;
|
|
1355
|
+
} catch {
|
|
1356
|
+
readType = -1;
|
|
1357
|
+
} finally {
|
|
1358
|
+
renderer.setRenderTarget(null);
|
|
1359
|
+
renderProbe.dispose();
|
|
1360
|
+
readProbe.dispose();
|
|
1361
|
+
}
|
|
1362
|
+
}
|
|
1363
|
+
_hdrReadback = halfFloatCaptureSupported({ readable, readType, halfFloat });
|
|
1364
|
+
if (!_hdrReadback) {
|
|
1365
|
+
console.warn("partforge: this device can't read back HDR renders; realistic captures are tone-mapped from 8-bit (highlights clip)");
|
|
1366
|
+
}
|
|
1367
|
+
return _hdrReadback;
|
|
1368
|
+
}
|
|
911
1369
|
//
|
|
912
1370
|
// `viewOffset` ({ fullWidth, fullHeight, x, y }) renders the width×height
|
|
913
1371
|
// output as that sub-window of a larger virtual frame — the recentred
|
|
@@ -918,10 +1376,18 @@ export function createViewer(container, part) {
|
|
|
918
1376
|
{ width = _rtSize, height = _rtSize, fov = 45, quality = 0.9,
|
|
919
1377
|
projection = "perspective", orthoHalfH = 1, viewOffset, sceneBounds } = {},
|
|
920
1378
|
renderScene = scene) {
|
|
921
|
-
|
|
1379
|
+
// A realistic LIVE scene renders HDR into a half-float target and is tone
|
|
1380
|
+
// mapped here (three tone-maps only the canvas path). It is lit by its
|
|
1381
|
+
// environment alone, so the CAD capture lights stay out of it. A throwaway
|
|
1382
|
+
// scene (renderMeshPayloads' thumbnails) is CAD whatever the live mode.
|
|
1383
|
+
// Without HDR readback it falls back to the 8-bit target and tone-maps the
|
|
1384
|
+
// clipped linear bytes: highlights clip, but it is still the realistic look.
|
|
1385
|
+
const realistic = renderScene === scene && renderMode === "realistic";
|
|
1386
|
+
const hdr = realistic && hdrCaptureReadback();
|
|
1387
|
+
const cachedSize = !hdr && width === _rtSize && height === _rtSize;
|
|
922
1388
|
const rt = cachedSize
|
|
923
1389
|
? (_rt = _rt ?? new THREE.WebGLRenderTarget(_rtSize, _rtSize, RT_OPTIONS))
|
|
924
|
-
: new THREE.WebGLRenderTarget(width, height, RT_OPTIONS);
|
|
1390
|
+
: new THREE.WebGLRenderTarget(width, height, hdr ? { ...RT_OPTIONS, type: THREE.HalfFloatType } : RT_OPTIONS);
|
|
925
1391
|
_capLights = _capLights ?? createCaptureLights();
|
|
926
1392
|
// Canonical captures never pass `projection`, so agent-facing renders and
|
|
927
1393
|
// the CLI stay perspective no matter what the user is looking at. Built
|
|
@@ -936,18 +1402,22 @@ export function createViewer(container, part) {
|
|
|
936
1402
|
});
|
|
937
1403
|
if (viewOffset) cam.setViewOffset(viewOffset.fullWidth, viewOffset.fullHeight, viewOffset.x, viewOffset.y, width, height);
|
|
938
1404
|
const { position, up, target } = pose;
|
|
939
|
-
const buf = new Uint8Array(width * height * 4);
|
|
1405
|
+
const buf = hdr ? new Uint16Array(width * height * 4) : new Uint8Array(width * height * 4);
|
|
940
1406
|
// Swap the world-fixed key/fill for the camera-relative pair, for this one render
|
|
941
1407
|
// only. A DirectionalLight aims at its `target`, whose matrixWorld only updates
|
|
942
1408
|
// while it is in the scene graph, so both go in and both come back out.
|
|
943
|
-
const poses = captureLightPoses({ position, up, target });
|
|
944
1409
|
const { key: capKey, fill: capFill } = _capLights;
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
1410
|
+
// Restored to what they WERE, not to true: realistic mode keeps them off.
|
|
1411
|
+
const keyWas = liveLights.key.visible, fillWas = liveLights.fill.visible;
|
|
1412
|
+
if (!realistic) {
|
|
1413
|
+
const poses = captureLightPoses({ position, up, target });
|
|
1414
|
+
capKey.position.set(poses.key[0], poses.key[1], poses.key[2]);
|
|
1415
|
+
capFill.position.set(poses.fill[0], poses.fill[1], poses.fill[2]);
|
|
1416
|
+
for (const light of [capKey, capFill]) light.target.position.set(target[0], target[1], target[2]);
|
|
1417
|
+
liveLights.key.visible = false;
|
|
1418
|
+
liveLights.fill.visible = false;
|
|
1419
|
+
scene.add(capKey, capKey.target, capFill, capFill.target);
|
|
1420
|
+
}
|
|
951
1421
|
// A pick marker is transient UI feedback about a click, never part of the
|
|
952
1422
|
// part, so it belongs in no capture. It used to be near enough true that a
|
|
953
1423
|
// capture would miss one — a dot faded after 1200ms — but a HELD dot lives
|
|
@@ -957,8 +1427,12 @@ export function createViewer(container, part) {
|
|
|
957
1427
|
// than via canonicalCaptureHidden: captureCurrent deliberately ignores that
|
|
958
1428
|
// set. Only dots that were actually shown are restored, so this cannot
|
|
959
1429
|
// resurrect one the live canvas had hidden for its own reasons.
|
|
1430
|
+
// The pointer's hover highlight (selection/feature-highlight.js) is the
|
|
1431
|
+
// same kind of transient feedback, and it TINTS the surface under it: a
|
|
1432
|
+
// capture taken with the pointer resting on a part baked a translucent
|
|
1433
|
+
// blue wash over it (an orange PLA sample came out pink).
|
|
960
1434
|
const reshowFlashDots = [];
|
|
961
|
-
for (const dot of flashDots) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
|
|
1435
|
+
for (const dot of [...flashDots, ...captureHidden]) if (dot.visible) { dot.visible = false; reshowFlashDots.push(dot); }
|
|
962
1436
|
try {
|
|
963
1437
|
renderer.setRenderTarget(rt);
|
|
964
1438
|
renderer.render(renderScene, cam);
|
|
@@ -969,9 +1443,11 @@ export function createViewer(container, part) {
|
|
|
969
1443
|
// Never leave the user's own view unlit, pointed at the offscreen target,
|
|
970
1444
|
// or missing a marker the user is still looking at.
|
|
971
1445
|
renderer.setRenderTarget(null);
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1446
|
+
if (!realistic) {
|
|
1447
|
+
scene.remove(capKey, capKey.target, capFill, capFill.target);
|
|
1448
|
+
liveLights.key.visible = keyWas;
|
|
1449
|
+
liveLights.fill.visible = fillWas;
|
|
1450
|
+
}
|
|
975
1451
|
for (const dot of reshowFlashDots) dot.visible = true;
|
|
976
1452
|
if (!cachedSize) rt.dispose();
|
|
977
1453
|
}
|
|
@@ -979,16 +1455,26 @@ export function createViewer(container, part) {
|
|
|
979
1455
|
canvas.width = width; canvas.height = height;
|
|
980
1456
|
const ctx = canvas.getContext("2d");
|
|
981
1457
|
const img = ctx.createImageData(width, height);
|
|
1458
|
+
// Linear pixels → sRGB bytes: tone-mapped for a realistic capture, only
|
|
1459
|
+
// transfer-encoded for CAD (which renders LDR with no tone mapping).
|
|
1460
|
+
const encoded = realistic ? neutralToneMapToSrgb8(readbackToLinear(buf), renderer.toneMappingExposure) : srgbEncodeInPlace(buf);
|
|
982
1461
|
// flip rows (GL origin is bottom-left)
|
|
983
1462
|
for (let y = 0; y < height; y++) {
|
|
984
1463
|
const src = (height - 1 - y) * width * 4;
|
|
985
|
-
img.data.set(
|
|
1464
|
+
img.data.set(encoded.subarray(src, src + width * 4), y * width * 4);
|
|
986
1465
|
}
|
|
987
|
-
srgbEncodeInPlace(img.data);
|
|
988
1466
|
ctx.putImageData(img, 0, 0);
|
|
989
1467
|
return canvas.toDataURL("image/jpeg", quality);
|
|
990
1468
|
}
|
|
991
1469
|
|
|
1470
|
+
// Pointer feedback excluded from EVERY offscreen render (renderOffscreen),
|
|
1471
|
+
// showcase captures included — unlike canonicalCaptureHidden below.
|
|
1472
|
+
const captureHidden = new Set();
|
|
1473
|
+
function registerCaptureHidden(obj) {
|
|
1474
|
+
captureHidden.add(obj);
|
|
1475
|
+
return () => captureHidden.delete(obj);
|
|
1476
|
+
}
|
|
1477
|
+
|
|
992
1478
|
// Objects excluded from CANONICAL captures only (agent renders must stay
|
|
993
1479
|
// dimension-free); captureCurrent — the user-framed showcase capture —
|
|
994
1480
|
// deliberately does NOT consult this set.
|
|
@@ -1000,8 +1486,21 @@ export function createViewer(container, part) {
|
|
|
1000
1486
|
|
|
1001
1487
|
// Render the canonical camera angles offscreen, framed to whatever is visible,
|
|
1002
1488
|
// without disturbing the user's live view. Returns [{ view, dataUrl }].
|
|
1489
|
+
//
|
|
1490
|
+
// Always the CAD look, whatever the live view shows: these are the agent's
|
|
1491
|
+
// renders, and the editor's on-screen mode must never change what the agent
|
|
1492
|
+
// sees. A realistic live view lends its scene to CAD for the synchronous
|
|
1493
|
+
// capture and gets it back exactly (captureIn). renderViews is the one way
|
|
1494
|
+
// to ask for realistic canonical views.
|
|
1003
1495
|
function captureCanonicalViews(viewNames) {
|
|
1004
1496
|
if (disposed) return [];
|
|
1497
|
+
const box = getVisibleWorldBounds();
|
|
1498
|
+
if (!box || box.isEmpty()) return []; // nothing to draw: don't swap the look for it
|
|
1499
|
+
return captureIn("cad", null, () => canonicalViewsInCurrentLook(viewNames));
|
|
1500
|
+
}
|
|
1501
|
+
// The canonical views in whatever look the scene has right now — the body
|
|
1502
|
+
// both captureCanonicalViews and renderViews wrap in captureIn.
|
|
1503
|
+
function canonicalViewsInCurrentLook(viewNames) {
|
|
1005
1504
|
const box = getVisibleWorldBounds();
|
|
1006
1505
|
if (!box || box.isEmpty()) return [];
|
|
1007
1506
|
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
@@ -1017,18 +1516,37 @@ export function createViewer(container, part) {
|
|
|
1017
1516
|
bounds: { center, radius },
|
|
1018
1517
|
// The ENCLOSING radius, which is a different number from the framing one
|
|
1019
1518
|
// above: a box's corners reach √3 further than half its max extent, and
|
|
1020
|
-
// the depth planes have to clear the corners.
|
|
1021
|
-
|
|
1519
|
+
// the depth planes have to clear the corners. A realistic capture keeps
|
|
1520
|
+
// its ground disc, which is several part radii across, so it counts too.
|
|
1521
|
+
sceneBounds: (realisticRig && sceneDepthBounds()) || { center, radius: size.length() / 2 || 10 },
|
|
1022
1522
|
});
|
|
1023
1523
|
}
|
|
1024
1524
|
|
|
1025
1525
|
// One offscreen render of the user's CURRENT framing (live camera pose +
|
|
1026
1526
|
// orbit target, live aspect) at a caller-chosen resolution — the showcase
|
|
1027
1527
|
// capture. Returns a JPEG data URL, or null when disposed / nothing visible.
|
|
1528
|
+
//
|
|
1529
|
+
// `renderMode` picks the look. Omitted, it follows the live view (a gallery
|
|
1530
|
+
// "Capture from viewer" captures what the user sees). "cad" borrows CAD for
|
|
1531
|
+
// the synchronous capture if the live view is realistic. "realistic" from a
|
|
1532
|
+
// CAD view borrows the realistic look only if the current environment's
|
|
1533
|
+
// assets have ALREADY loaded — this call is synchronous and cannot wait for
|
|
1534
|
+
// them — and otherwise falls back to the live look; renderViews is the async
|
|
1535
|
+
// path that guarantees realistic.
|
|
1028
1536
|
function captureCurrent(opts) {
|
|
1029
1537
|
if (disposed) return null;
|
|
1030
1538
|
const box = getVisibleWorldBounds();
|
|
1031
1539
|
if (!box || box.isEmpty()) return null;
|
|
1540
|
+
const { renderMode: want, ...rest } = opts ?? {};
|
|
1541
|
+
const capture = () => currentFramingInCurrentLook(rest);
|
|
1542
|
+
if (want === "cad") return captureIn("cad", null, capture);
|
|
1543
|
+
if (want === "realistic" && renderMode !== "realistic") {
|
|
1544
|
+
const rig = loadedRigs.get(environmentId);
|
|
1545
|
+
if (rig) return captureIn("realistic", rig, capture);
|
|
1546
|
+
}
|
|
1547
|
+
return capture();
|
|
1548
|
+
}
|
|
1549
|
+
function currentFramingInCurrentLook(opts) {
|
|
1032
1550
|
return captureCurrentFromScene(opts, {
|
|
1033
1551
|
renderer: { renderOffscreen },
|
|
1034
1552
|
liveCamera: activeCamera,
|
|
@@ -1049,6 +1567,53 @@ export function createViewer(container, part) {
|
|
|
1049
1567
|
});
|
|
1050
1568
|
}
|
|
1051
1569
|
|
|
1570
|
+
// Run one synchronous capture in the `want` look, leaving the live view
|
|
1571
|
+
// exactly as it was — even if the capture throws. The swap and the restore
|
|
1572
|
+
// sit in one synchronous block, so the canvas never paints a borrowed frame,
|
|
1573
|
+
// and nothing is published: the live mode never changed. A parked viewer
|
|
1574
|
+
// still captures (offscreen work), so its shadow is rendered on demand.
|
|
1575
|
+
function captureIn(want, rig, capture) {
|
|
1576
|
+
if (want === renderMode) {
|
|
1577
|
+
if (want === "realistic" && !active) renderShadow(undefined, { force: true });
|
|
1578
|
+
return capture();
|
|
1579
|
+
}
|
|
1580
|
+
if (want === "realistic") {
|
|
1581
|
+
enterRealistic(rig, { live: false }); // rolls itself back to CAD if it throws
|
|
1582
|
+
try { return capture(); } finally { enterCad({ live: false }); }
|
|
1583
|
+
}
|
|
1584
|
+
const liveRig = realisticRig, movedAt = shadowMovedAt;
|
|
1585
|
+
try {
|
|
1586
|
+
enterCad({ live: false });
|
|
1587
|
+
return capture();
|
|
1588
|
+
} finally {
|
|
1589
|
+
try {
|
|
1590
|
+
enterRealistic(liveRig, { live: false, reground: false });
|
|
1591
|
+
shadowMovedAt = movedAt;
|
|
1592
|
+
} catch (e) {
|
|
1593
|
+
// enterRealistic already rolled the scene back to CAD: make it the live mode.
|
|
1594
|
+
console.warn("partforge: restoring the realistic view after a capture failed", e);
|
|
1595
|
+
try { applyPixelRatio("cad"); } catch { /* the view is CAD either way */ }
|
|
1596
|
+
publishMode({ error: "couldn't load realistic view" });
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
// Agent-facing canonical renders in a chosen appearance, whatever the live
|
|
1602
|
+
// view shows. CAD is exactly captureCanonicalViews (which is always CAD).
|
|
1603
|
+
// Realistic waits for the current environment's rig and the capture's
|
|
1604
|
+
// shader programs, then borrows the realistic look for the synchronous
|
|
1605
|
+
// capture only if the live view is not already realistic. A rig that fails
|
|
1606
|
+
// to load rejects rather than silently returning CAD images.
|
|
1607
|
+
async function renderViews(viewNames, { renderMode: want = "cad" } = {}) {
|
|
1608
|
+
if (disposed) return [];
|
|
1609
|
+
if (want !== "realistic") return captureCanonicalViews(viewNames);
|
|
1610
|
+
const rig = await rigFor(environmentId);
|
|
1611
|
+
if (disposed) return [];
|
|
1612
|
+
await compileRealistic(rig, { forCapture: true });
|
|
1613
|
+
if (disposed) return [];
|
|
1614
|
+
return captureIn("realistic", rig, () => canonicalViewsInCurrentLook(viewNames));
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1052
1617
|
// Offscreen render of an arbitrary mesh set (a non-active view), for thumbnails.
|
|
1053
1618
|
// Assembles a THROWAWAY scene mirroring the live pivot convention, frames it from a
|
|
1054
1619
|
// canonical angle, renders through the parameterized renderOffscreen, and disposes
|
|
@@ -1153,6 +1718,7 @@ export function createViewer(container, part) {
|
|
|
1153
1718
|
for (const cb of [...frameListeners]) {
|
|
1154
1719
|
try { cb(dt); } catch (e) { console.warn("partforge: frame listener failed", e); }
|
|
1155
1720
|
}
|
|
1721
|
+
updateMovingShadow(); // after the listeners, so it sees this frame's poses
|
|
1156
1722
|
// After the frame listeners, before anything reads the camera to draw with:
|
|
1157
1723
|
// a playback frame may have moved sub-parts or the camera itself, and both
|
|
1158
1724
|
// change where the planes belong.
|
|
@@ -1206,6 +1772,9 @@ export function createViewer(container, part) {
|
|
|
1206
1772
|
return;
|
|
1207
1773
|
}
|
|
1208
1774
|
resize(); // rebuild the buffer at whatever size the container is now
|
|
1775
|
+
// A parked viewer renders no contact shadow (placeGround skips it), so a
|
|
1776
|
+
// regen while hidden left it stale.
|
|
1777
|
+
placeGround();
|
|
1209
1778
|
lastFrameTime = null; // parked time is not elapsed time — no dt jump on unpark
|
|
1210
1779
|
renderer.setAnimationLoop(renderFrame);
|
|
1211
1780
|
}
|
|
@@ -1409,9 +1978,11 @@ export function createViewer(container, part) {
|
|
|
1409
1978
|
for (const n of names) {
|
|
1410
1979
|
const g = subCache[n];
|
|
1411
1980
|
if (g) { g.userData.edges?.dispose(); g.dispose(); subCache[n] = null; }
|
|
1412
|
-
//
|
|
1413
|
-
// the mesh onto a clone, and the base material would otherwise leak.
|
|
1414
|
-
baseMats[n]
|
|
1981
|
+
// cadMats[n], not subMesh[n].material: an active fade override has swapped
|
|
1982
|
+
// the mesh onto a clone, and the base material would otherwise leak. Not
|
|
1983
|
+
// baseMats[n] either — in realistic mode that is a physical material,
|
|
1984
|
+
// freed with the rest of them in disposeRealistic below.
|
|
1985
|
+
cadMats[n]?.dispose();
|
|
1415
1986
|
subMesh[n].geometry?.dispose(); // the initial empty BufferGeometry, if never replaced
|
|
1416
1987
|
}
|
|
1417
1988
|
// Hand the fade clones back before freeing them. cutaway.dispose() above has
|
|
@@ -1425,6 +1996,7 @@ export function createViewer(container, part) {
|
|
|
1425
1996
|
for (const m of fadeLineMats.values()) m.dispose();
|
|
1426
1997
|
fadeMats.clear();
|
|
1427
1998
|
fadeLineMats.clear();
|
|
1999
|
+
disposeRealistic();
|
|
1428
2000
|
material.dispose();
|
|
1429
2001
|
lineMaterial.dispose();
|
|
1430
2002
|
grid.geometry.dispose();
|
|
@@ -1447,6 +2019,7 @@ export function createViewer(container, part) {
|
|
|
1447
2019
|
captureCanonicalViews,
|
|
1448
2020
|
captureCurrent,
|
|
1449
2021
|
renderMeshPayloads,
|
|
2022
|
+
renderViews,
|
|
1450
2023
|
onFrame,
|
|
1451
2024
|
tweenCameraTo,
|
|
1452
2025
|
cancelCameraTween,
|
|
@@ -1489,7 +2062,23 @@ export function createViewer(container, part) {
|
|
|
1489
2062
|
getCutawayPlane: cutaway.getPlane,
|
|
1490
2063
|
registerCutawayMaterial: cutaway.registerClippableMaterial,
|
|
1491
2064
|
registerCanonicalCaptureHidden,
|
|
2065
|
+
registerCaptureHidden,
|
|
1492
2066
|
onCutawayHandleHover: cutaway.onHandleHoverChange,
|
|
2067
|
+
setRenderMode,
|
|
2068
|
+
getRenderMode: () => renderMode,
|
|
2069
|
+
onRenderModeChange: (cb) => { modeListeners.add(cb); return () => modeListeners.delete(cb); },
|
|
2070
|
+
setEnvironment,
|
|
2071
|
+
getEnvironment: () => environmentId,
|
|
2072
|
+
// Whether the environment was named through setEnvironment (by the user or
|
|
2073
|
+
// a host) rather than seeded from the part's meta.environment / the
|
|
2074
|
+
// default — so a carried viewer state only pins an environment someone chose.
|
|
2075
|
+
isEnvironmentChosen: () => environmentChosen,
|
|
2076
|
+
// A realistic request is loading (getRenderMode still reads the mode on
|
|
2077
|
+
// screen until it lands); false once it lands, fails, or is superseded.
|
|
2078
|
+
isRealisticPending: () => realisticPending,
|
|
2079
|
+
onEnvironmentChange: (cb) => { envListeners.add(cb); return () => envListeners.delete(cb); },
|
|
2080
|
+
setPrintFrames,
|
|
2081
|
+
whenRealisticReady,
|
|
1493
2082
|
dispose,
|
|
1494
2083
|
};
|
|
1495
2084
|
}
|