partforge 0.146.4 → 0.149.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 +25 -0
- package/docs/AUTHORING-PARTS.md +49 -0
- package/package.json +12 -1
- package/src/framework/app.css +66 -1
- package/src/framework/appearance-card.js +331 -0
- package/src/framework/default-view.js +6 -3
- package/src/framework/gltf/decode-preview.js +89 -0
- package/src/framework/gltf/encode-preview.js +142 -0
- package/src/framework/materials/overrides.js +120 -0
- package/src/framework/materials/thumb-cache.js +33 -0
- package/src/framework/mount-preview.js +157 -0
- package/src/framework/mount.js +247 -52
- package/src/framework/parts-list.js +116 -0
- package/src/framework/preview-gate.js +37 -0
- package/src/framework/sub-part-list.js +44 -0
- package/src/framework/view-style-controls.js +110 -5
- package/src/framework/viewbar-clear.js +23 -0
- package/src/framework/viewer.js +377 -49
- package/src/gltf.js +6 -0
- package/src/index.js +5 -0
- package/src/testing.js +1 -0
- package/types/gltf.d.ts +40 -0
- package/types/index.d.ts +80 -0
- package/types/testing.d.ts +3 -0
package/src/framework/viewer.js
CHANGED
|
@@ -13,7 +13,9 @@ import { loadEnvironmentRig } from "./materials/environment.js";
|
|
|
13
13
|
import { assetUrl } from "./materials/assets.js";
|
|
14
14
|
import { resolveEnvironmentId } from "./materials/resolve.js";
|
|
15
15
|
import { burnsFor, realisticDisplay } from "./materials/sheet-look.js";
|
|
16
|
+
import { effectiveDisplay, mergeOverride, sanitizeDisplayPatch, subPartVisibility, APPEARANCE_OVERRIDE_KEYS, VIEWING_KEYS } from "./materials/overrides.js";
|
|
16
17
|
import { neutralToneMapToSrgb8 } from "./materials/tonemap-readback.js";
|
|
18
|
+
import { createMaterialThumbCache, thumbDisplay } from "./materials/thumb-cache.js";
|
|
17
19
|
import { createCutaway } from "./cutaway.js";
|
|
18
20
|
import { CUTAWAY_OVERLAY_RENDER_ORDER } from "./cutaway-render.js";
|
|
19
21
|
import { flashWorldRadius, projectToScreen, anchorMoved } from "./pick-flash.js";
|
|
@@ -62,7 +64,7 @@ export function halfFloatCaptureSupported({ readable, readType = null, halfFloat
|
|
|
62
64
|
// a GL context. The grid is hidden for the whole synchronous pass and restored.
|
|
63
65
|
// `poseFor(view)`, when given, places each view's camera in place of
|
|
64
66
|
// cameraPoseForView(view, bounds) — the viewer passes the cad style's fit.
|
|
65
|
-
export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, sceneBounds, hidden = [], poseFor }) {
|
|
67
|
+
export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, bounds, sceneBounds, hidden = [], reveal = [], poseFor }) {
|
|
66
68
|
const views = (viewNames?.length ? viewNames : ["iso", "front", "top"])
|
|
67
69
|
.filter((v) => CANONICAL_VIEWS.includes(v))
|
|
68
70
|
.slice(0, CANONICAL_VIEWS.length);
|
|
@@ -71,6 +73,9 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
|
|
|
71
73
|
if (grid) grid.visible = false;
|
|
72
74
|
const hiddenWas = hidden.map((o) => o.visible);
|
|
73
75
|
for (const o of hidden) o.visible = false;
|
|
76
|
+
// `reveal`: [{ show, restore }] — sub-parts the user hid or faded, shown as
|
|
77
|
+
// if untouched for THIS pass only (agent renders must never miss a piece).
|
|
78
|
+
for (const r of reveal) r.show();
|
|
74
79
|
try {
|
|
75
80
|
return views.map((view) => {
|
|
76
81
|
const pose = poseFor ? poseFor(view) : cameraPoseForView(view, bounds);
|
|
@@ -81,6 +86,7 @@ export function captureViewsFromScene(viewNames, { renderer, liveCamera, grid, b
|
|
|
81
86
|
return { view, dataUrl: renderer.renderOffscreen(pose, { sceneBounds }) };
|
|
82
87
|
});
|
|
83
88
|
} finally {
|
|
89
|
+
for (const r of reveal) r.restore();
|
|
84
90
|
if (grid) grid.visible = gridWasVisible;
|
|
85
91
|
hidden.forEach((o, i) => { o.visible = hiddenWas[i]; });
|
|
86
92
|
liveCamera.position.copy(before); // belt-and-suspenders: never leak camera state
|
|
@@ -159,6 +165,11 @@ export function captureCurrentFromScene(
|
|
|
159
165
|
|
|
160
166
|
export function createViewer(container, part) {
|
|
161
167
|
const names = Object.keys(part.parts);
|
|
168
|
+
// Membership for host-supplied names. subMesh, cadMats and part.parts are
|
|
169
|
+
// plain objects, so "toString" or "constructor" would read as present on
|
|
170
|
+
// them; every name a host hands the override API is checked here instead.
|
|
171
|
+
const nameSet = new Set(names);
|
|
172
|
+
const has = (name) => nameSet.has(name);
|
|
162
173
|
|
|
163
174
|
// --- renderer / scene / camera --------------------------------------------
|
|
164
175
|
const renderer = new THREE.WebGLRenderer({ antialias: true, stencil: true });
|
|
@@ -265,11 +276,21 @@ export function createViewer(container, part) {
|
|
|
265
276
|
const partsGroup = new THREE.Group();
|
|
266
277
|
pivot.add(partsGroup);
|
|
267
278
|
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
279
|
+
// --- per-sub-part display overrides (materials/overrides.js) ----------------
|
|
280
|
+
// Appearance keys merge over the authored display for EVERY material read
|
|
281
|
+
// below (effectiveDisplayOf); the viewing keys (hidden, opacity) are read only
|
|
282
|
+
// by applySubOpacity / effectiveVisible. Carried by mount in viewerState.
|
|
283
|
+
const displayOverrides = new Map(); // name -> override (never empty)
|
|
284
|
+
const displayListeners = new Set();
|
|
285
|
+
const effectiveDisplayOf = (name) => (has(name) ? effectiveDisplay(part.parts[name]?.display, displayOverrides.get(name) ?? null) : {});
|
|
286
|
+
const isHidden = (name) => displayOverrides.get(name)?.hidden === true;
|
|
287
|
+
const userOpacityOf = (name) => displayOverrides.get(name)?.opacity; // undefined = none
|
|
288
|
+
|
|
289
|
+
// Per-sub-part CAD material: the shared default unless the EFFECTIVE display
|
|
290
|
+
// declares appearance (colour, opacity, or a library material flattened for
|
|
291
|
+
// the CAD view — materials/resolve.js cadAppearance).
|
|
271
292
|
function materialFor(name) {
|
|
272
|
-
return buildCadMaterial(
|
|
293
|
+
return buildCadMaterial(effectiveDisplayOf(name), material);
|
|
273
294
|
}
|
|
274
295
|
|
|
275
296
|
const subMesh = Object.fromEntries(
|
|
@@ -315,6 +336,11 @@ export function createViewer(container, part) {
|
|
|
315
336
|
let renderMode = "cad";
|
|
316
337
|
let realisticRig = null;
|
|
317
338
|
let shadowMovedAt = null; // performance.now() of a pose change the contact shadow has not caught up with
|
|
339
|
+
// The appearance card's sphere thumbnails (renderMaterialThumbnail). Up here,
|
|
340
|
+
// ahead of every look change that clears it (setTheme, enterRealistic, enterCad).
|
|
341
|
+
const materialThumbs = createMaterialThumbCache();
|
|
342
|
+
let _thumbSphere = null;
|
|
343
|
+
|
|
318
344
|
|
|
319
345
|
// three calls scene.onBeforeRender(renderer, scene, camera, target) at the
|
|
320
346
|
// very start of WebGLRenderer.render — after scene/camera matrixWorld are
|
|
@@ -337,7 +363,11 @@ export function createViewer(container, part) {
|
|
|
337
363
|
if (renderMode === "realistic") realisticRig?.updateForCamera?.(camera);
|
|
338
364
|
};
|
|
339
365
|
|
|
340
|
-
|
|
366
|
+
// A sub-part the user hid, or faded to nothing (the user floor is above 0, so
|
|
367
|
+
// only the animation can), is out of the visible set: the cutaway, the
|
|
368
|
+
// ground, raycast and visible-bounds framing all follow.
|
|
369
|
+
const effectiveVisible = () => lastShown.filter((n) =>
|
|
370
|
+
!isHidden(n) && (animOpacity.get(n) ?? 1) > 0 && (userOpacityOf(n) ?? 1) > 0);
|
|
341
371
|
|
|
342
372
|
// A fade clone is a material the cutaway does not own, so it has to be told
|
|
343
373
|
// about the clipping plane explicitly — otherwise a mid-fade part renders
|
|
@@ -375,45 +405,48 @@ export function createViewer(container, part) {
|
|
|
375
405
|
return m;
|
|
376
406
|
}
|
|
377
407
|
|
|
378
|
-
// Re-derive one sub-part's material + visibility from (shown,
|
|
408
|
+
// Re-derive one sub-part's material + visibility from (shown, animation
|
|
409
|
+
// opacity, the user's hide/see-through) through the one pure decision,
|
|
410
|
+
// materials/overrides.js subPartVisibility.
|
|
411
|
+
//
|
|
412
|
+
// Restore ONLY from our own fade clone. showAssembly runs on every regen
|
|
413
|
+
// (mount.js's refreshView) without disabling the cutaway, and an enabled
|
|
414
|
+
// cutaway has swapped these onto its clipped clones
|
|
415
|
+
// (createSectionRenderSet.setEnabled) — an unconditional write here would
|
|
416
|
+
// silently drop clipping on every sub-part on the next param edit.
|
|
417
|
+
// When we just took the mesh off our clone, an enabled cutaway must get the
|
|
418
|
+
// chance to re-claim it onto its clipped clone — the base material we write
|
|
419
|
+
// carries no plane, and nothing else would put it back until the next
|
|
420
|
+
// cutaway toggle or theme change. Guarded on hadFade so the every-regen
|
|
421
|
+
// showAssembly path stays a no-op for un-faded sub-parts.
|
|
379
422
|
function applySubOpacity(name) {
|
|
423
|
+
if (!has(name)) return;
|
|
380
424
|
const mesh = subMesh[name], lines = subLines[name];
|
|
381
425
|
if (!mesh) return;
|
|
382
|
-
const
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
426
|
+
const { meshVisible, linesVisible, fade } = subPartVisibility({
|
|
427
|
+
shown: lastShown.includes(name),
|
|
428
|
+
anim: animOpacity.get(name),
|
|
429
|
+
user: userOpacityOf(name),
|
|
430
|
+
hidden: isHidden(name),
|
|
431
|
+
renderMode,
|
|
432
|
+
staticOpacity: effectiveDisplayOf(name).opacity ?? 1,
|
|
433
|
+
});
|
|
434
|
+
if (fade === null) {
|
|
390
435
|
const hadFade = mesh.material === fadeMats.get(name);
|
|
391
436
|
if (hadFade) mesh.material = baseMats[name];
|
|
392
437
|
if (lines.material === fadeLineMats.get(name)) lines.material = lineMaterial;
|
|
393
|
-
// We just took the mesh off our clone, so an enabled cutaway must get the
|
|
394
|
-
// chance to re-claim it onto its clipped clone — the base material we
|
|
395
|
-
// wrote above carries no plane, and nothing else would put it back until
|
|
396
|
-
// the next cutaway toggle or theme change. Guarded on hadFade so the
|
|
397
|
-
// every-regen showAssembly path stays a no-op for un-faded sub-parts.
|
|
398
438
|
if (hadFade) cutaway.resyncSubpart(name);
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
439
|
+
} else {
|
|
440
|
+
const fm = fadeMatFor(name);
|
|
441
|
+
fm.opacity = fade;
|
|
442
|
+
mesh.material = fm;
|
|
443
|
+
const flm = fadeLineMatFor(name);
|
|
444
|
+
const so = effectiveDisplayOf(name).opacity ?? 1;
|
|
445
|
+
flm.opacity = so > 0 ? fade / so : 0; // lines fade by the multiplier alone
|
|
446
|
+
lines.material = flm;
|
|
407
447
|
}
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
fm.opacity = staticOpacity * v;
|
|
411
|
-
mesh.material = fm;
|
|
412
|
-
const flm = fadeLineMatFor(name);
|
|
413
|
-
flm.opacity = v;
|
|
414
|
-
lines.material = flm;
|
|
415
|
-
mesh.visible = shown;
|
|
416
|
-
lines.visible = shown && renderMode === "cad";
|
|
448
|
+
mesh.visible = meshVisible;
|
|
449
|
+
lines.visible = linesVisible && meshVisible;
|
|
417
450
|
}
|
|
418
451
|
|
|
419
452
|
function setSubPartOpacity(name, value) {
|
|
@@ -441,6 +474,91 @@ export function createViewer(container, part) {
|
|
|
441
474
|
cutaway.setVisible(effectiveVisible());
|
|
442
475
|
}
|
|
443
476
|
|
|
477
|
+
// --- display overrides: the API (spec A.2) -----------------------------------
|
|
478
|
+
function publishDisplay(evt) {
|
|
479
|
+
for (const cb of [...displayListeners]) {
|
|
480
|
+
try { cb(evt); } catch (e) { console.warn("partforge: display listener failed", e); }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// Re-point one sub-part at the materials its EFFECTIVE display now means, in
|
|
484
|
+
// whichever mode is live — no geometry work (rebaseSubMaterial's contract).
|
|
485
|
+
// A sub-part with no appearance shares the base `material` (buildCadMaterial
|
|
486
|
+
// returns it), which every other such sub-part is also on: never dispose it.
|
|
487
|
+
function rematerialize(name) {
|
|
488
|
+
if (cadMats[name] !== material) cadMats[name]?.dispose();
|
|
489
|
+
const cad = materialFor(name);
|
|
490
|
+
cadMats[name] = cad;
|
|
491
|
+
const phys = physicalMats.get(name);
|
|
492
|
+
if (phys) { phys.dispose(); physicalMats.delete(name); }
|
|
493
|
+
if (renderMode === "realistic") {
|
|
494
|
+
if (subCache[name] && physicalFor(name).userData.pfAnisotropic) ensureBoxUVs(subCache[name]);
|
|
495
|
+
rebaseSubMaterial(name, physicalFor(name));
|
|
496
|
+
} else {
|
|
497
|
+
rebaseSubMaterial(name, cad);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
function visibilityChanged() {
|
|
501
|
+
matchGeneration++;
|
|
502
|
+
cutaway.setVisible(effectiveVisible());
|
|
503
|
+
if (realisticRig) { shadowMovedAt = performance.now(); placeGround(); }
|
|
504
|
+
}
|
|
505
|
+
// Shallow, key-by-key: effective displays hold only primitives.
|
|
506
|
+
function sameDisplay(a, b) {
|
|
507
|
+
const ka = Object.keys(a), kb = Object.keys(b);
|
|
508
|
+
return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && Object.is(a[k], b[k]));
|
|
509
|
+
}
|
|
510
|
+
function setSubPartDisplay(name, patch) {
|
|
511
|
+
if (!has(name) || !subMesh[name]) return null;
|
|
512
|
+
const before = displayOverrides.get(name) ?? null;
|
|
513
|
+
const clean = sanitizeDisplayPatch(patch);
|
|
514
|
+
// Nothing survived sanitizing: not a change, so nothing is rebuilt and no
|
|
515
|
+
// listener hears it (a host saving on onDisplayChange would checkpoint a
|
|
516
|
+
// no-op). The mount-realistic tests pin this through material identity.
|
|
517
|
+
if (!Object.keys(clean).length) return before ? { ...before } : null;
|
|
518
|
+
const effectiveBefore = effectiveDisplayOf(name);
|
|
519
|
+
const next = mergeOverride(before, clean);
|
|
520
|
+
if (next) displayOverrides.set(name, next); else displayOverrides.delete(name);
|
|
521
|
+
// An appearance key that lands on the value already drawn (the same
|
|
522
|
+
// material picked twice) rebuilds nothing.
|
|
523
|
+
const appearanceMoved = APPEARANCE_OVERRIDE_KEYS.some((k) => k in clean) && !sameDisplay(effectiveBefore, effectiveDisplayOf(name));
|
|
524
|
+
const viewingMoved = VIEWING_KEYS.some((k) => k in clean);
|
|
525
|
+
if (appearanceMoved) rematerialize(name); else if (viewingMoved) applySubOpacity(name);
|
|
526
|
+
if (viewingMoved) visibilityChanged();
|
|
527
|
+
requestRender();
|
|
528
|
+
publishDisplay({ name, override: next ? { ...next } : null });
|
|
529
|
+
return next ? { ...next } : null;
|
|
530
|
+
}
|
|
531
|
+
function getSubPartDisplayState(name) {
|
|
532
|
+
if (!has(name) || !subMesh[name]) return null;
|
|
533
|
+
const override = displayOverrides.get(name) ?? null;
|
|
534
|
+
return {
|
|
535
|
+
authored: part.parts[name].display ?? null,
|
|
536
|
+
override: override ? { ...override } : null,
|
|
537
|
+
effective: effectiveDisplayOf(name),
|
|
538
|
+
hidden: isHidden(name),
|
|
539
|
+
opacity: userOpacityOf(name) ?? 1,
|
|
540
|
+
};
|
|
541
|
+
}
|
|
542
|
+
const getDisplayOverrides = () => Object.fromEntries([...displayOverrides].map(([n, o]) => [n, { ...o }]));
|
|
543
|
+
// Clear the viewing keys, the appearance keys, or both — for one sub-part or all.
|
|
544
|
+
function clearSubPartDisplay({ viewing = false, appearance = false, name = null } = {}) {
|
|
545
|
+
const targets = name ? (has(name) && displayOverrides.has(name) ? [name] : []) : [...displayOverrides.keys()];
|
|
546
|
+
if (!targets.length) return;
|
|
547
|
+
for (const n of targets) {
|
|
548
|
+
const o = displayOverrides.get(n);
|
|
549
|
+
const patch = {};
|
|
550
|
+
if (viewing) for (const k of VIEWING_KEYS) if (k in o) patch[k] = null;
|
|
551
|
+
if (appearance) for (const k of APPEARANCE_OVERRIDE_KEYS) if (k in o) patch[k] = null;
|
|
552
|
+
const next = mergeOverride(o, patch);
|
|
553
|
+
if (next) displayOverrides.set(n, next); else displayOverrides.delete(n);
|
|
554
|
+
if (appearance) rematerialize(n); else applySubOpacity(n);
|
|
555
|
+
}
|
|
556
|
+
visibilityChanged();
|
|
557
|
+
requestRender();
|
|
558
|
+
publishDisplay(null);
|
|
559
|
+
}
|
|
560
|
+
function onDisplayChange(cb) { displayListeners.add(cb); return () => displayListeners.delete(cb); }
|
|
561
|
+
|
|
444
562
|
// The cutaway plane lives in world space, so its initial/reset bounds must
|
|
445
563
|
// include the pivot rotation and the per-view recentering transform —
|
|
446
564
|
// mesh.matrixWorld carries both. Union each visible mesh's own
|
|
@@ -724,10 +842,13 @@ export function createViewer(container, part) {
|
|
|
724
842
|
let m = physicalMats.get(name);
|
|
725
843
|
if (!m) {
|
|
726
844
|
const sp = part.parts[name];
|
|
727
|
-
// `burn` is decided
|
|
845
|
+
// `burn` is decided per sub-part material (sheet-look.js burnsFor): only a laser sheet in
|
|
728
846
|
// a wood compiles the burn pass, so every other program is exactly what it was.
|
|
729
847
|
// realisticDisplay: a laser sheet with no material draws as its stock (sheet-look.js).
|
|
730
|
-
|
|
848
|
+
// Both read sp.display, so hand them a sub-part whose display is the EFFECTIVE one
|
|
849
|
+
// (authored + the user's appearance override).
|
|
850
|
+
const eff = { ...sp, display: effectiveDisplayOf(name) };
|
|
851
|
+
m = cloneKeepsPattern(buildPhysicalMaterial(realisticDisplay(eff), { printFrame: printFrames[name], burn: burnsFor(eff), loadTexture }));
|
|
731
852
|
physicalMats.set(name, m);
|
|
732
853
|
syncGrain(name);
|
|
733
854
|
applySheetFrame(m, sheetFrames[name]);
|
|
@@ -776,9 +897,10 @@ export function createViewer(container, part) {
|
|
|
776
897
|
}
|
|
777
898
|
}
|
|
778
899
|
|
|
779
|
-
// Ghosts (a static display opacity below 1) cast no shadow
|
|
900
|
+
// Ghosts (a static display opacity below 1) cast no shadow — a USER fade
|
|
901
|
+
// (see-through) counts as a ghost too.
|
|
780
902
|
const castersNow = () => names
|
|
781
|
-
.filter((n) => subMesh[n].visible && (
|
|
903
|
+
.filter((n) => subMesh[n].visible && (effectiveDisplayOf(n).opacity ?? 1) >= 1 && userOpacityOf(n) === undefined)
|
|
782
904
|
.map((n) => subMesh[n]);
|
|
783
905
|
// `force` renders even while parked: a capture is offscreen work a parked
|
|
784
906
|
// viewer still does, and it must not bake a stale (or absent) shadow.
|
|
@@ -882,6 +1004,9 @@ export function createViewer(container, part) {
|
|
|
882
1004
|
if (live) applyPixelRatio("realistic");
|
|
883
1005
|
// Hides the lines on every sub-part now that renderMode reads realistic.
|
|
884
1006
|
refreshFeatureLines();
|
|
1007
|
+
// A capture's borrowed look (live: false) is put back at once: the
|
|
1008
|
+
// thumbnails still describe the live one.
|
|
1009
|
+
if (live) invalidateMaterialThumbnails();
|
|
885
1010
|
} catch (e) {
|
|
886
1011
|
try { enterCad({ live }); } catch (rollback) { console.warn("partforge: rolling back to CAD failed", rollback); }
|
|
887
1012
|
throw e;
|
|
@@ -916,6 +1041,7 @@ export function createViewer(container, part) {
|
|
|
916
1041
|
for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
|
|
917
1042
|
if (live) attempt(() => applyPixelRatio("cad"));
|
|
918
1043
|
attempt(refreshFeatureLines);
|
|
1044
|
+
if (live) invalidateMaterialThumbnails();
|
|
919
1045
|
if (firstError) throw firstError;
|
|
920
1046
|
}
|
|
921
1047
|
|
|
@@ -1020,6 +1146,7 @@ export function createViewer(container, part) {
|
|
|
1020
1146
|
environmentChosen = true;
|
|
1021
1147
|
if (next !== environmentId) announceEnvironment(next);
|
|
1022
1148
|
if (renderMode === "realistic" || realisticPending) await setRenderMode("realistic");
|
|
1149
|
+
invalidateMaterialThumbnails();
|
|
1023
1150
|
return environmentId;
|
|
1024
1151
|
}
|
|
1025
1152
|
|
|
@@ -1371,7 +1498,7 @@ export function createViewer(container, part) {
|
|
|
1371
1498
|
for (const n of names) {
|
|
1372
1499
|
const mesh = subMesh[n];
|
|
1373
1500
|
if (!mesh.visible || !mesh.geometry?.boundingBox) continue;
|
|
1374
|
-
if ((
|
|
1501
|
+
if ((effectiveDisplayOf(n).opacity ?? 1) < 1 || userOpacityOf(n) !== undefined) continue; // a ghost (authored or user see-through) is seen through
|
|
1375
1502
|
mesh.updateWorldMatrix(true, false);
|
|
1376
1503
|
_matchProxy.geometry = mesh.geometry;
|
|
1377
1504
|
_matchProxy.matrixWorld.copy(mesh.matrixWorld);
|
|
@@ -1642,6 +1769,39 @@ export function createViewer(container, part) {
|
|
|
1642
1769
|
const hasSubMesh = (name) => !!subCache[name];
|
|
1643
1770
|
const subTriangles = (name) => subCache[name]?.userData.triangles ?? 0;
|
|
1644
1771
|
|
|
1772
|
+
// The geometry drawn for one sub-part, as a fresh worker-style payload with its
|
|
1773
|
+
// fast-path pose (setSubPose) baked in — the posed mesh a headless buildView
|
|
1774
|
+
// would return. Copies: the caller (exportPreview → partforge/gltf) may keep or
|
|
1775
|
+
// transfer them. null when nothing has been delivered under that name.
|
|
1776
|
+
const _payloadV = new THREE.Vector3();
|
|
1777
|
+
const _payloadNormal = new THREE.Matrix3();
|
|
1778
|
+
function getSubMeshPayload(name) {
|
|
1779
|
+
const geo = subCache[name];
|
|
1780
|
+
if (!geo) return null;
|
|
1781
|
+
const pose = subMesh[name].matrix; // identity unless the fast path posed it
|
|
1782
|
+
_payloadNormal.getNormalMatrix(pose);
|
|
1783
|
+
const pos = geo.getAttribute("position");
|
|
1784
|
+
const positions = new Float32Array(pos.count * 3);
|
|
1785
|
+
for (let i = 0; i < pos.count; i++) {
|
|
1786
|
+
_payloadV.fromBufferAttribute(pos, i).applyMatrix4(pose).toArray(positions, i * 3);
|
|
1787
|
+
}
|
|
1788
|
+
const nor = geo.getAttribute("normal");
|
|
1789
|
+
let normals = null;
|
|
1790
|
+
if (nor) {
|
|
1791
|
+
normals = new Float32Array(nor.count * 3);
|
|
1792
|
+
for (let i = 0; i < nor.count; i++) {
|
|
1793
|
+
_payloadV.fromBufferAttribute(nor, i).applyMatrix3(_payloadNormal).normalize().toArray(normals, i * 3);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
return {
|
|
1797
|
+
name,
|
|
1798
|
+
positions,
|
|
1799
|
+
normals,
|
|
1800
|
+
indices: geo.index ? Uint32Array.from(geo.index.array) : null,
|
|
1801
|
+
triangles: geo.userData.triangles ?? (geo.index ? geo.index.count : pos.count) / 3,
|
|
1802
|
+
};
|
|
1803
|
+
}
|
|
1804
|
+
|
|
1645
1805
|
// --- show / hide assembly -------------------------------------------------
|
|
1646
1806
|
const _box = new THREE.Box3();
|
|
1647
1807
|
const _posedBox = new THREE.Box3();
|
|
@@ -1755,6 +1915,7 @@ export function createViewer(container, part) {
|
|
|
1755
1915
|
cutaway.setTheme(mode, t.line);
|
|
1756
1916
|
reassertLiveFades(); // setTheme re-clones every section's materials and reassigns them
|
|
1757
1917
|
currentTheme = THEME[mode] ? mode : "dark";
|
|
1918
|
+
invalidateMaterialThumbnails();
|
|
1758
1919
|
for (const cb of [...themeListeners]) cb(currentTheme);
|
|
1759
1920
|
}
|
|
1760
1921
|
|
|
@@ -1870,7 +2031,9 @@ export function createViewer(container, part) {
|
|
|
1870
2031
|
// scene (renderMeshPayloads' thumbnails) is CAD whatever the live mode.
|
|
1871
2032
|
// Without HDR readback it falls back to the 8-bit target and tone-maps the
|
|
1872
2033
|
// clipped linear bytes: highlights clip, but it is still the realistic look.
|
|
1873
|
-
|
|
2034
|
+
// A throwaway scene that carries an environment (renderMaterialThumbnail's
|
|
2035
|
+
// realistic sphere) is realistic too; renderMeshPayloads' never has one.
|
|
2036
|
+
const realistic = renderMode === "realistic" && (renderScene === scene || renderScene.environment != null);
|
|
1874
2037
|
const hdr = realistic && hdrCaptureReadback();
|
|
1875
2038
|
const cachedSize = !hdr && width === _rtSize && height === _rtSize;
|
|
1876
2039
|
const rt = cachedSize
|
|
@@ -2001,6 +2164,70 @@ export function createViewer(container, part) {
|
|
|
2001
2164
|
return () => canonicalCaptureHidden.delete(obj);
|
|
2002
2165
|
}
|
|
2003
2166
|
|
|
2167
|
+
// Sub-parts the user hid or faded, as `reveal` entries: shown as if untouched
|
|
2168
|
+
// for one capture pass (agent renders must never miss a piece), then put back.
|
|
2169
|
+
// Built and shown INSIDE a borrowed look (captureIn), never around one: a look
|
|
2170
|
+
// swap re-runs applySubOpacity on every sub-part, which would re-hide them.
|
|
2171
|
+
//
|
|
2172
|
+
// Accepted limitation: a mesh taken off a USER fade clone for the pass sits on
|
|
2173
|
+
// baseMats[n], which carries no cutaway plane, so with the cutaway enabled that
|
|
2174
|
+
// one piece renders unclipped in that one capture. A hidden, unfaded mesh keeps
|
|
2175
|
+
// whatever material it was on (the cutaway's clipped clone included), the same
|
|
2176
|
+
// hadFade rule applySubOpacity follows. The ground and contact shadow are not
|
|
2177
|
+
// re-derived for the pass either: they stay as the live view placed them.
|
|
2178
|
+
function revealEntries() {
|
|
2179
|
+
const out = [];
|
|
2180
|
+
for (const n of names) {
|
|
2181
|
+
if (!lastShown.includes(n) || !subCache[n]) continue;
|
|
2182
|
+
if (!isHidden(n) && userOpacityOf(n) === undefined) continue;
|
|
2183
|
+
const mesh = subMesh[n], lines = subLines[n];
|
|
2184
|
+
const fm = fadeMats.get(n), flm = fadeLineMats.get(n);
|
|
2185
|
+
// The fade clones are shared with the live view, so their opacities are
|
|
2186
|
+
// part of what the pass must put back, beside the assignments.
|
|
2187
|
+
const was = {
|
|
2188
|
+
mat: mesh.material, lmat: lines.material, vis: mesh.visible, lvis: lines.visible,
|
|
2189
|
+
fmOpacity: fm?.opacity, flmOpacity: flm?.opacity,
|
|
2190
|
+
};
|
|
2191
|
+
out.push({
|
|
2192
|
+
object: mesh,
|
|
2193
|
+
show: () => {
|
|
2194
|
+
const staticOpacity = effectiveDisplayOf(n).opacity ?? 1;
|
|
2195
|
+
const { meshVisible, linesVisible, fade } = subPartVisibility({
|
|
2196
|
+
shown: true, anim: animOpacity.get(n), user: undefined, hidden: false, renderMode, staticOpacity,
|
|
2197
|
+
});
|
|
2198
|
+
if (fade === null) {
|
|
2199
|
+
if (mesh.material === fadeMats.get(n)) mesh.material = baseMats[n];
|
|
2200
|
+
if (lines.material === fadeLineMats.get(n)) lines.material = lineMaterial;
|
|
2201
|
+
} else {
|
|
2202
|
+
const f = fadeMatFor(n); f.opacity = fade; mesh.material = f;
|
|
2203
|
+
const fl = fadeLineMatFor(n); fl.opacity = staticOpacity > 0 ? fade / staticOpacity : 0; lines.material = fl;
|
|
2204
|
+
}
|
|
2205
|
+
mesh.visible = meshVisible; lines.visible = linesVisible && meshVisible;
|
|
2206
|
+
},
|
|
2207
|
+
restore: () => {
|
|
2208
|
+
const f = fadeMats.get(n), fl = fadeLineMats.get(n);
|
|
2209
|
+
if (f) f.opacity = was.fmOpacity ?? f.opacity;
|
|
2210
|
+
if (fl) fl.opacity = was.flmOpacity ?? fl.opacity;
|
|
2211
|
+
mesh.material = was.mat; lines.material = was.lmat; mesh.visible = was.vis; lines.visible = was.lvis;
|
|
2212
|
+
},
|
|
2213
|
+
});
|
|
2214
|
+
}
|
|
2215
|
+
return out;
|
|
2216
|
+
}
|
|
2217
|
+
// Run `fn` with the user's hidden/faded sub-parts revealed (when `on`), and
|
|
2218
|
+
// put them back after — even if `fn` throws.
|
|
2219
|
+
function withRevealed(on, fn) {
|
|
2220
|
+
const reveal = on ? revealEntries() : [];
|
|
2221
|
+
// Only entries whose show() ran (even partly, if it threw) are put back;
|
|
2222
|
+
// restore() writes back values recorded before show(), so it is safe either way.
|
|
2223
|
+
const shown = [];
|
|
2224
|
+
try {
|
|
2225
|
+
for (const r of reveal) { shown.push(r); r.show(); }
|
|
2226
|
+
return fn();
|
|
2227
|
+
} finally { for (const r of shown) r.restore(); }
|
|
2228
|
+
}
|
|
2229
|
+
const hasVisibleBounds = () => { const b = getVisibleWorldBounds(); return !!b && !b.isEmpty(); };
|
|
2230
|
+
|
|
2004
2231
|
// Render the canonical camera angles offscreen, framed to whatever is visible,
|
|
2005
2232
|
// without disturbing the user's live view. Returns [{ view, dataUrl }].
|
|
2006
2233
|
//
|
|
@@ -2012,13 +2239,21 @@ export function createViewer(container, part) {
|
|
|
2012
2239
|
// borrowing CAD for this capture draws them for free.
|
|
2013
2240
|
function captureCanonicalViews(viewNames) {
|
|
2014
2241
|
if (disposed) return [];
|
|
2015
|
-
|
|
2016
|
-
|
|
2242
|
+
// Asked with the hidden pieces revealed, or a part whose every sub-part the
|
|
2243
|
+
// user hid would return [] instead of its render.
|
|
2244
|
+
if (!withRevealed(true, hasVisibleBounds)) return []; // nothing to draw: don't swap the look for it
|
|
2017
2245
|
return captureIn("cad", null, () => canonicalViewsInCurrentLook(viewNames));
|
|
2018
2246
|
}
|
|
2019
2247
|
// The canonical views in whatever look the scene has right now — the body
|
|
2020
2248
|
// both captureCanonicalViews and renderViews wrap in captureIn.
|
|
2249
|
+
// The user's hidden and faded sub-parts are revealed for the whole body,
|
|
2250
|
+
// framing included: getVisibleWorldBounds reads mesh.visible before
|
|
2251
|
+
// captureViewsFromScene runs (whose own `reveal` option stays for a caller
|
|
2252
|
+
// that frames itself).
|
|
2021
2253
|
function canonicalViewsInCurrentLook(viewNames) {
|
|
2254
|
+
return withRevealed(true, () => canonicalViewsRevealed(viewNames));
|
|
2255
|
+
}
|
|
2256
|
+
function canonicalViewsRevealed(viewNames) {
|
|
2022
2257
|
const box = getVisibleWorldBounds();
|
|
2023
2258
|
if (!box || box.isEmpty()) return [];
|
|
2024
2259
|
const center = box.getCenter(new THREE.Vector3()).toArray();
|
|
@@ -2054,12 +2289,15 @@ export function createViewer(container, part) {
|
|
|
2054
2289
|
// assets have ALREADY loaded — this call is synchronous and cannot wait for
|
|
2055
2290
|
// them — and otherwise falls back to the live look; renderViews is the async
|
|
2056
2291
|
// path that guarantees realistic.
|
|
2292
|
+
//
|
|
2293
|
+
// `includeHidden: true` (build sight) renders the user's hidden and faded
|
|
2294
|
+
// sub-parts as if untouched; omitted, the capture is what the user sees.
|
|
2057
2295
|
function captureCurrent(opts) {
|
|
2058
2296
|
if (disposed) return null;
|
|
2059
|
-
const
|
|
2060
|
-
if (!
|
|
2061
|
-
|
|
2062
|
-
const capture = () => currentFramingInCurrentLook(rest);
|
|
2297
|
+
const { renderMode: want, includeHidden = false, ...rest } = opts ?? {};
|
|
2298
|
+
if (!withRevealed(includeHidden, hasVisibleBounds)) return null;
|
|
2299
|
+
// Revealed inside the capture, so inside any borrowed look (revealEntries).
|
|
2300
|
+
const capture = () => withRevealed(includeHidden, () => currentFramingInCurrentLook(rest));
|
|
2063
2301
|
if (want === "cad") return captureIn("cad", null, capture);
|
|
2064
2302
|
if (want === "realistic" && renderMode !== "realistic") {
|
|
2065
2303
|
const rig = loadedRigs.get(environmentId);
|
|
@@ -2162,6 +2400,83 @@ export function createViewer(container, part) {
|
|
|
2162
2400
|
}
|
|
2163
2401
|
}
|
|
2164
2402
|
|
|
2403
|
+
// A sphere in `display`'s material under the current look, for the
|
|
2404
|
+
// appearance card's tiles. A THROWAWAY scene (never the live one): in CAD it
|
|
2405
|
+
// is the flattened CAD material under the capture lights; in realistic it is
|
|
2406
|
+
// the physical material lit by the live rig's envMap with its tone mapping,
|
|
2407
|
+
// in front of the rig's own backdrop (which is what a clear material shows
|
|
2408
|
+
// through — three's transmission pass draws the background, and with none
|
|
2409
|
+
// acrylic reads as black glass). The sphere slightly overfills the frame so
|
|
2410
|
+
// the tile's round crop shows sphere only, never the backdrop's corners.
|
|
2411
|
+
// Cached per (material, colour, environment, theme, mode); null on failure.
|
|
2412
|
+
// The environment in the key is the rig actually LIT: setEnvironment
|
|
2413
|
+
// announces the new id before its rig loads, and a sphere drawn in that
|
|
2414
|
+
// window under the old rig must not be cached as the new one.
|
|
2415
|
+
const thumbKey = (display) => materialThumbs.key(
|
|
2416
|
+
display, (renderMode === "realistic" && realisticRig?.id) || environmentId, currentTheme, renderMode);
|
|
2417
|
+
async function renderMaterialThumbnail(display, { size = 96 } = {}) {
|
|
2418
|
+
if (disposed) return null;
|
|
2419
|
+
const key = thumbKey(display);
|
|
2420
|
+
const hit = materialThumbs.get(key);
|
|
2421
|
+
if (hit !== undefined) return hit;
|
|
2422
|
+
// Built from the key's own fields only (the preset, tinted), never the
|
|
2423
|
+
// sub-part's finish overrides or opacity — see thumb-cache.js.
|
|
2424
|
+
const look = thumbDisplay(display);
|
|
2425
|
+
let mat = null;
|
|
2426
|
+
try {
|
|
2427
|
+
const tmp = new THREE.Scene();
|
|
2428
|
+
const rig = renderMode === "realistic" ? realisticRig : null;
|
|
2429
|
+
if (rig) {
|
|
2430
|
+
mat = buildPhysicalMaterial(look, { loadTexture });
|
|
2431
|
+
// A textured preset (oak) would otherwise draw untextured, and be cached so.
|
|
2432
|
+
await whenTexturesSettled();
|
|
2433
|
+
// The look moved while the maps loaded: this sphere is for a key nobody
|
|
2434
|
+
// shows any more (the card re-renders on the change anyway).
|
|
2435
|
+
if (disposed || rig !== realisticRig || key !== thumbKey(display)) {
|
|
2436
|
+
mat.dispose();
|
|
2437
|
+
return null;
|
|
2438
|
+
}
|
|
2439
|
+
tmp.environment = rig.envMap;
|
|
2440
|
+
tmp.environmentRotation.set(0, rig.rotationY ?? 0, 0);
|
|
2441
|
+
tmp.background = rig.background ?? null;
|
|
2442
|
+
tmp.backgroundBlurriness = rig.backgroundBlurriness ?? 0;
|
|
2443
|
+
tmp.backgroundIntensity = rig.backgroundIntensity ?? 1;
|
|
2444
|
+
tmp.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
|
|
2445
|
+
// A rig's own lights (an enclosure's overhead LED bar) belong to the
|
|
2446
|
+
// live scene; a fresh one of the same strength stands in from above.
|
|
2447
|
+
for (const l of rig.lights ?? []) {
|
|
2448
|
+
const d = new THREE.DirectionalLight(l.color, l.intensity);
|
|
2449
|
+
d.position.set(0.5, 4, 1);
|
|
2450
|
+
tmp.add(d, d.target);
|
|
2451
|
+
}
|
|
2452
|
+
} else {
|
|
2453
|
+
mat = buildCadMaterial(look, material);
|
|
2454
|
+
tmp.background = new THREE.Color(THEME[currentTheme].bg);
|
|
2455
|
+
tmp.add(createHemisphereLight());
|
|
2456
|
+
const { key: k, fill: f } = createCaptureLights();
|
|
2457
|
+
k.position.set(3, 4, 5); f.position.set(-4, 1, 5);
|
|
2458
|
+
tmp.add(k, k.target, f, f.target);
|
|
2459
|
+
}
|
|
2460
|
+
_thumbSphere = _thumbSphere ?? new THREE.SphereGeometry(1, 48, 32);
|
|
2461
|
+
tmp.add(new THREE.Mesh(_thumbSphere, mat));
|
|
2462
|
+
const url = renderOffscreen(
|
|
2463
|
+
{ position: [0, 0, 3.2], up: [0, 1, 0], target: [0, 0, 0] },
|
|
2464
|
+
{ width: size, height: size, fov: 35, quality: 0.85, sceneBounds: { center: [0, 0, 0], radius: 1.2 } },
|
|
2465
|
+
tmp,
|
|
2466
|
+
);
|
|
2467
|
+
materialThumbs.set(key, url);
|
|
2468
|
+
return url;
|
|
2469
|
+
} catch (e) {
|
|
2470
|
+
console.warn("partforge: material thumbnail failed", e);
|
|
2471
|
+
return null;
|
|
2472
|
+
} finally {
|
|
2473
|
+
// Never the shared CAD base (buildCadMaterial returns it for a display
|
|
2474
|
+
// with no appearance); the textures are loadTexture's cache, not ours.
|
|
2475
|
+
if (mat && mat !== material) mat.dispose();
|
|
2476
|
+
}
|
|
2477
|
+
}
|
|
2478
|
+
function invalidateMaterialThumbnails() { materialThumbs.clear(); }
|
|
2479
|
+
|
|
2165
2480
|
// Agent-facing canonical renders in a chosen appearance, whatever the live
|
|
2166
2481
|
// view shows. CAD is exactly captureCanonicalViews (which is always CAD).
|
|
2167
2482
|
// Realistic waits for the current environment's rig and the capture's
|
|
@@ -2669,6 +2984,8 @@ export function createViewer(container, part) {
|
|
|
2669
2984
|
assemblyListeners.clear();
|
|
2670
2985
|
themeListeners.clear();
|
|
2671
2986
|
projectionListeners.clear();
|
|
2987
|
+
displayListeners.clear();
|
|
2988
|
+
displayOverrides.clear();
|
|
2672
2989
|
canonicalCaptureHidden.clear();
|
|
2673
2990
|
camTween.cancel();
|
|
2674
2991
|
controls.dispose();
|
|
@@ -2685,6 +3002,8 @@ export function createViewer(container, part) {
|
|
|
2685
3002
|
lastAnchor = null;
|
|
2686
3003
|
anchorListeners.clear();
|
|
2687
3004
|
flashGeometry.dispose();
|
|
3005
|
+
_thumbSphere?.dispose();
|
|
3006
|
+
materialThumbs.clear();
|
|
2688
3007
|
cutaway.dispose();
|
|
2689
3008
|
for (const n of names) {
|
|
2690
3009
|
const g = subCache[n];
|
|
@@ -2724,15 +3043,24 @@ export function createViewer(container, part) {
|
|
|
2724
3043
|
setSubGeometry,
|
|
2725
3044
|
setSubPose,
|
|
2726
3045
|
setSubPartOpacity,
|
|
3046
|
+
setSubPartDisplay,
|
|
3047
|
+
getSubPartDisplayState,
|
|
3048
|
+
getDisplayOverrides,
|
|
3049
|
+
clearSubPartDisplay,
|
|
3050
|
+
onDisplayChange,
|
|
3051
|
+
effectiveDisplayOf,
|
|
2727
3052
|
clearSubPartOpacities,
|
|
2728
3053
|
hasSubMesh,
|
|
2729
3054
|
subTriangles,
|
|
3055
|
+
getSubMeshPayload,
|
|
2730
3056
|
frame,
|
|
2731
3057
|
captureCanonicalViews,
|
|
2732
3058
|
captureCurrent,
|
|
2733
3059
|
renderMeshPayloads,
|
|
2734
3060
|
renderViews,
|
|
2735
3061
|
renderStyleThumbnail,
|
|
3062
|
+
renderMaterialThumbnail,
|
|
3063
|
+
invalidateMaterialThumbnails,
|
|
2736
3064
|
onFrame,
|
|
2737
3065
|
onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
|
|
2738
3066
|
tweenCameraTo,
|
package/src/gltf.js
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// partforge/gltf — the cached-preview writer. One view's posed display meshes as a
|
|
2
|
+
// small GLB (see src/framework/gltf/encode-preview.js for the file's contract).
|
|
3
|
+
// Worker-safe: no three.js, no DOM, so it runs in Node (a server-side preview job),
|
|
4
|
+
// a page (runtime.exportPreview) and a Web Worker alike. Read the files back with
|
|
5
|
+
// mount()'s `preview` option or mountPreview(), both on the main entry.
|
|
6
|
+
export { encodePreviewGlb, PREVIEW_ENCODER_VERSION } from "./framework/gltf/encode-preview.js";
|
package/src/index.js
CHANGED
|
@@ -3,8 +3,13 @@
|
|
|
3
3
|
// must NOT be imported from a part's build functions — those run in a Web Worker.
|
|
4
4
|
// Part build functions import geometry helpers from "partforge/geometry" instead.
|
|
5
5
|
export { mount } from "./framework/index.js";
|
|
6
|
+
// The kernel-free viewer for a cached preview GLB (partforge/gltf): no part, no worker.
|
|
7
|
+
export { mountPreview } from "./framework/mount-preview.js";
|
|
6
8
|
export { viewSubParts } from "./framework/part-model.js";
|
|
7
9
|
// The cut & print kit's options surface, for a host that draws its own options screen
|
|
8
10
|
// (partforge-cloud's coupling test reads KIT_OPTIONS_ERROR from here). export/formats.js
|
|
9
11
|
// is import-free, so this adds nothing to the app bundle but a few constants.
|
|
10
12
|
export { EXPORT_FORMATS, KIT_OPTIONS_ERROR, KIT_DEFAULTS, validateKitOptions } from "./framework/export/formats.js";
|
|
13
|
+
// Which view mount() opens on — for a host that builds a part outside the viewer
|
|
14
|
+
// (a cached preview, a server-side render) and must agree with it.
|
|
15
|
+
export { resolveDefaultView } from "./framework/default-view.js";
|
package/src/testing.js
CHANGED
|
@@ -11,6 +11,7 @@ export { createManifoldKernel } from "./framework/geometry/manifold-backend.js";
|
|
|
11
11
|
export { bootManifoldKernel } from "./testing/manifold.js";
|
|
12
12
|
export { handle } from "./framework/jobs.js";
|
|
13
13
|
export { viewSubParts } from "./framework/part-model.js";
|
|
14
|
+
export { resolveDefaultView } from "./framework/default-view.js";
|
|
14
15
|
export { resolveDerived } from "./framework/derive.js";
|
|
15
16
|
export { relevantParamKeys, RELEVANT_ALL } from "./framework/param-deps.js";
|
|
16
17
|
export { assemblyOverlaps } from "./framework/assembly.js";
|