partforge 0.167.0 → 0.168.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 +26 -0
- package/package.json +1 -1
- package/src/framework/mount-preview.js +158 -18
- package/src/framework/viewer.js +229 -10
- package/types/index.d.ts +39 -1
package/README.md
CHANGED
|
@@ -187,11 +187,37 @@ stock, no layer-line frames). Previews carry no edge lines, textures or patterns
|
|
|
187
187
|
`resolveDefaultView(part)` (main entry and `partforge/testing`) names the view
|
|
188
188
|
`mount()` opens on, so a preview built headlessly matches it.
|
|
189
189
|
|
|
190
|
+
`mountPreview` takes presentation options (0.168.0+), each usable alone and
|
|
191
|
+
each defaulting to the viewer above: `chrome: false` (no view cube, viewbar
|
|
192
|
+
controls, view style, tooltips or play button), `background: "transparent"`
|
|
193
|
+
(alpha-0 canvas, no grid) and `contactShadow: true` (a soft shadow under the
|
|
194
|
+
part) — both CAD mode only, since realistic mode draws its own environment and
|
|
195
|
+
ground — `zoom: false`
|
|
196
|
+
and `pan: false` (a wheel then scrolls the page), `turntable: <degrees per
|
|
197
|
+
second>` (a model with no playing animation turns until the first camera grab,
|
|
198
|
+
never under `prefers-reduced-motion`), `fill: 0.8` (frame the model so its
|
|
199
|
+
bounding box, as seen from the opening view, spans that fraction of the preview
|
|
200
|
+
along its tighter axis — larger in a small card; with a turntable the model swept
|
|
201
|
+
a full turn is framed, so it never clips as it turns and a round part still fills; the model is
|
|
202
|
+
centred on its geometry, so a lopsided part turns about its hub), `camera: "default"` (drop the carried camera
|
|
203
|
+
and projection and keep the file's own framing; a carried render mode and
|
|
204
|
+
environment still apply) and `renderMode: "cad" | "realistic"` (the opening
|
|
205
|
+
mode, over any carried one; it can change afterwards). An unknown option key
|
|
206
|
+
throws. A baked animation always plays as authored — it
|
|
207
|
+
loops, or plays once and holds its end pose. Every handle carries `drawn`, which
|
|
208
|
+
resolves after the first frame showing the meshes, so a host can cross-fade from
|
|
209
|
+
a still image.
|
|
210
|
+
|
|
190
211
|
```js
|
|
191
212
|
const glb = await runtime.exportPreview(); // Uint8Array: the view on screen
|
|
192
213
|
mount(part, { createWorker, elements, preview: glb }); // drawn at once, replaced by the build
|
|
193
214
|
const shown = mountPreview(stage, { glb }); // no kernel at all
|
|
194
215
|
await shown.ready;
|
|
216
|
+
const card = mountPreview(cardStage, { // a landing-page card
|
|
217
|
+
glb, chrome: false, background: "transparent", contactShadow: true,
|
|
218
|
+
zoom: false, pan: false, turntable: 14, fill: 0.8, camera: "default", renderMode: "cad",
|
|
219
|
+
});
|
|
220
|
+
await card.drawn; // the first frame is on the canvas
|
|
195
221
|
```
|
|
196
222
|
|
|
197
223
|
Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
|
package/package.json
CHANGED
|
@@ -30,6 +30,51 @@
|
|
|
30
30
|
// file, with `animate: false`, or for a host with no #viewbar; the host can
|
|
31
31
|
// still drive `handle.animation` (state / pause / resume / replay) itself.
|
|
32
32
|
//
|
|
33
|
+
// Presentation options (0.168.0), each usable alone; every default is the
|
|
34
|
+
// viewer above, so a mount that names none is unchanged:
|
|
35
|
+
// - `chrome: false` — no view cube, no #viewbar controls, no view style, no
|
|
36
|
+
// tooltips, no play button and no `elements.chrome` wiring. A one-time
|
|
37
|
+
// animation still holds its end pose; there is just no replay button.
|
|
38
|
+
// - `background: "transparent"` — the canvas clears to alpha 0 with no theme
|
|
39
|
+
// background and no grid, so the host page shows through. CAD mode ONLY:
|
|
40
|
+
// realistic mode draws its own environment backdrop (pair it with
|
|
41
|
+
// `renderMode: "cad"` to open transparent).
|
|
42
|
+
// - `contactShadow: true` — the product shot's soft contact shadow under the
|
|
43
|
+
// part. CAD mode ONLY: realistic mode draws its own ground and shadow. Cast
|
|
44
|
+
// once, at showAssembly, from the meshes as the file stores them (the rest
|
|
45
|
+
// pose, before a player applies its first pose); a playing animation does
|
|
46
|
+
// not move it.
|
|
47
|
+
// - `zoom: false`, `pan: false` — those orbit gestures are off (a wheel then
|
|
48
|
+
// scrolls the page); orbiting stays on.
|
|
49
|
+
// - `turntable: <degrees per second>` (default 0, off) — a file with no
|
|
50
|
+
// playing animation (a still file, or `animate: false`) turns about the
|
|
51
|
+
// vertical off the viewer's frame loop. Never under reduced motion; the
|
|
52
|
+
// first camera grab — a canvas drag or the view cube — stops it for good.
|
|
53
|
+
// Dragging an animated model orbits the camera and the animation plays on.
|
|
54
|
+
// - `fill: <0 < f <= 1>` — the opening framing (and the viewer's reframe)
|
|
55
|
+
// places the camera, along the kept view direction, so the model's posed
|
|
56
|
+
// bounding box as projected on screen spans that fraction of the viewport
|
|
57
|
+
// along its limiting axis. A turning model is framed swept a full turn
|
|
58
|
+
// about the vertical (its farthest vertex from the axis), so the turntable
|
|
59
|
+
// never clips and a round part still fills. The model is centred on its
|
|
60
|
+
// horizontal vertex centroid (a lopsided part turns about its hub); a baked
|
|
61
|
+
// animation that moves parts outside the rest box can. Unset, the viewer's
|
|
62
|
+
// fixed framing distance, as before.
|
|
63
|
+
// - `camera: "default"` — the camera and projection of `viewerState` and of
|
|
64
|
+
// every setViewerState are dropped: the mount opens, and stays, at the
|
|
65
|
+
// file's own default framing. Their render mode and environment still apply.
|
|
66
|
+
// - `renderMode: "cad" | "realistic"` — the OPENING mode: applied at mount,
|
|
67
|
+
// over any carried render mode. After that it is an ordinary mode —
|
|
68
|
+
// setViewerState, renderMode.set and the view style control change it.
|
|
69
|
+
// Unset, the carried state decides, as before.
|
|
70
|
+
// Any other key throws (an option this version does not know is a host bug).
|
|
71
|
+
//
|
|
72
|
+
// `handle.drawn` resolves after the first frame that includes the meshes has
|
|
73
|
+
// rendered (so a host can cross-fade from a still image without a flash). It
|
|
74
|
+
// rejects with `ready`'s error when the file will not decode, and rejects if
|
|
75
|
+
// the handle is disposed before that frame. A parked preview draws nothing, so
|
|
76
|
+
// `drawn` waits for setActive(true).
|
|
77
|
+
//
|
|
33
78
|
// The decoder loads on demand (it carries the
|
|
34
79
|
// GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
|
|
35
80
|
// Hence `ready`. Every handle method is safe before it resolves.
|
|
@@ -45,6 +90,66 @@ import { ENVIRONMENTS } from "./materials/environments.js";
|
|
|
45
90
|
import { createPreviewPlayer } from "./preview-player.js";
|
|
46
91
|
import { attachPreviewPlayButton } from "./preview-play-button.js";
|
|
47
92
|
|
|
93
|
+
// A value the option does not know is a host bug: say so at the call rather
|
|
94
|
+
// than quietly showing the default.
|
|
95
|
+
function oneOf(name, value, allowed, fallback) {
|
|
96
|
+
if (value === undefined) return fallback;
|
|
97
|
+
if (!allowed.includes(value)) throw new TypeError(`mountPreview: ${name} must be ${allowed.map((a) => JSON.stringify(a)).join(" or ")}`);
|
|
98
|
+
return value;
|
|
99
|
+
}
|
|
100
|
+
function flag(name, value, fallback) {
|
|
101
|
+
if (value === undefined) return fallback;
|
|
102
|
+
if (typeof value !== "boolean") throw new TypeError(`mountPreview: ${name} must be a boolean`);
|
|
103
|
+
return value;
|
|
104
|
+
}
|
|
105
|
+
const KNOWN_OPTIONS = new Set([
|
|
106
|
+
"glb", "elements", "viewerState", "animate",
|
|
107
|
+
"chrome", "background", "contactShadow", "zoom", "pan", "turntable", "fill", "camera", "renderMode",
|
|
108
|
+
]);
|
|
109
|
+
|
|
110
|
+
function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode }) {
|
|
111
|
+
if (fill !== undefined && !(typeof fill === "number" && fill > 0 && fill <= 1)) {
|
|
112
|
+
throw new TypeError("mountPreview: fill must be a number above 0 and at most 1");
|
|
113
|
+
}
|
|
114
|
+
if (turntable !== undefined && !(typeof turntable === "number" && Number.isFinite(turntable) && turntable >= 0)) {
|
|
115
|
+
throw new TypeError("mountPreview: turntable must be degrees per second, 0 or more");
|
|
116
|
+
}
|
|
117
|
+
return {
|
|
118
|
+
chrome: flag("chrome", chrome, true),
|
|
119
|
+
transparent: oneOf("background", background, ["theme", "transparent"], "theme") === "transparent",
|
|
120
|
+
contactShadow: flag("contactShadow", contactShadow, false),
|
|
121
|
+
zoom: flag("zoom", zoom, true),
|
|
122
|
+
pan: flag("pan", pan, true),
|
|
123
|
+
turntable: turntable ?? 0,
|
|
124
|
+
fill: fill ?? null,
|
|
125
|
+
defaultCamera: oneOf("camera", camera, ["carry", "default"], "carry") === "default",
|
|
126
|
+
renderMode: oneOf("renderMode", renderMode, ["cad", "realistic"], null),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Turn the camera about the vertical at `degPerSec` until the first camera grab.
|
|
131
|
+
// It turns through viewer.orbitBy, which itself announces a camera grab — so the
|
|
132
|
+
// turntable's own steps are told apart from the user's by a flag held across
|
|
133
|
+
// the call. Returns stop().
|
|
134
|
+
function startTurntable(viewer, container, degPerSec) {
|
|
135
|
+
let turning = false, stopped = false;
|
|
136
|
+
const offFrame = viewer.onFrame((dt) => {
|
|
137
|
+
// orbitBy's rate: a drag the full stage height is a full turn.
|
|
138
|
+
const dx = ((degPerSec * dt) / 360) * Math.max(1, container.clientHeight || 1);
|
|
139
|
+
if (!(dx > 0)) return;
|
|
140
|
+
turning = true;
|
|
141
|
+
try { viewer.orbitBy(dx, 0); } finally { turning = false; }
|
|
142
|
+
});
|
|
143
|
+
const offGrab = viewer.onCameraStart(() => { if (!turning) stop(); });
|
|
144
|
+
function stop() {
|
|
145
|
+
if (stopped) return;
|
|
146
|
+
stopped = true;
|
|
147
|
+
offFrame();
|
|
148
|
+
offGrab();
|
|
149
|
+
}
|
|
150
|
+
return stop;
|
|
151
|
+
}
|
|
152
|
+
|
|
48
153
|
function previewPart({ meshes, view, meta }) {
|
|
49
154
|
return {
|
|
50
155
|
meta: { title: "Preview", ...(meta ?? {}) },
|
|
@@ -65,24 +170,43 @@ const relay = (listeners) => (event) => {
|
|
|
65
170
|
}
|
|
66
171
|
};
|
|
67
172
|
|
|
68
|
-
export function mountPreview(container,
|
|
173
|
+
export function mountPreview(container, options = {}) {
|
|
69
174
|
if (!container) throw new TypeError("mountPreview: a container element is required");
|
|
175
|
+
for (const key of Object.keys(options ?? {})) {
|
|
176
|
+
if (!KNOWN_OPTIONS.has(key)) throw new TypeError(`mountPreview: unknown option "${key}"`);
|
|
177
|
+
}
|
|
178
|
+
const { glb, elements = {}, viewerState = null, animate = true } = options ?? {};
|
|
179
|
+
const opts = presentation(options ?? {});
|
|
180
|
+
// A carried state as this mount applies it: under camera: "default" without
|
|
181
|
+
// its camera and projection (render mode and environment still carry).
|
|
182
|
+
const carry = (state) => {
|
|
183
|
+
if (!state || !opts.defaultCamera) return state ?? null;
|
|
184
|
+
const { camera: _camera, projection: _projection, ...rest } = state;
|
|
185
|
+
return rest;
|
|
186
|
+
};
|
|
70
187
|
let disposed = false;
|
|
71
188
|
let active = true;
|
|
72
|
-
|
|
189
|
+
// Applied once the viewer exists, and on every setViewerState. The opening
|
|
190
|
+
// render mode, when one is named, wins over the carried one at mount only.
|
|
191
|
+
let carried = carry(viewerState);
|
|
192
|
+
if (opts.renderMode) carried = { ...(carried ?? {}), renderMode: opts.renderMode };
|
|
73
193
|
let live = null; // { viewer, view } once the GLB has decoded
|
|
74
194
|
let player = null; // the baked animation's player, once drawn
|
|
75
195
|
const cleanups = [];
|
|
76
196
|
const modeListeners = new Set();
|
|
77
197
|
const envListeners = new Set();
|
|
78
198
|
const lostListeners = new Set();
|
|
199
|
+
let resolveDrawn, rejectDrawn;
|
|
200
|
+
const drawn = new Promise((resolve, reject) => { resolveDrawn = resolve; rejectDrawn = reject; });
|
|
201
|
+
drawn.catch(() => {}); // the host awaits it; never an unhandled rejection
|
|
79
202
|
|
|
80
203
|
// Same precedence mount() uses: projection first (orthographic only for a
|
|
81
204
|
// face-view camera), then the camera, then environment and render mode.
|
|
82
205
|
function applyState(s) {
|
|
83
206
|
if (!s || !live) return;
|
|
84
207
|
const { viewer } = live;
|
|
85
|
-
|
|
208
|
+
// Under camera: "default" the framing is the file's own: no projection either.
|
|
209
|
+
if (!opts.defaultCamera) viewer.setProjection(s.projection === "orthographic" && isFaceAlignedState(s.camera) ? "orthographic" : "perspective");
|
|
86
210
|
if (s.camera) viewer.setCameraState(s.camera);
|
|
87
211
|
if (typeof s.environment === "string") viewer.setEnvironment(s.environment);
|
|
88
212
|
if ((s.renderMode === "cad" || s.renderMode === "realistic") && s.renderMode !== viewer.getRenderMode()) {
|
|
@@ -94,18 +218,29 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
94
218
|
if (disposed) return;
|
|
95
219
|
if (decoded.meshes.length === 0) throw new Error("mountPreview: the preview holds no meshes");
|
|
96
220
|
const view = decoded.view ?? "preview";
|
|
97
|
-
const
|
|
221
|
+
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
222
|
+
const plays = !!decoded.animation && animate !== false;
|
|
223
|
+
const turns = !plays && opts.turntable > 0 && !reducedMotion;
|
|
224
|
+
const viewer = createViewer(container, previewPart({ ...decoded, view }), {
|
|
225
|
+
transparent: opts.transparent, contactShadow: opts.contactShadow, zoom: opts.zoom, pan: opts.pan,
|
|
226
|
+
// A turning model is framed swept a full turn, so no angle of it clips.
|
|
227
|
+
frameFill: opts.fill, frameSwept: turns,
|
|
228
|
+
});
|
|
98
229
|
cleanups.push(() => viewer.dispose());
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
230
|
+
let tooltip = null, toolbar = null, viewStyle = null;
|
|
231
|
+
if (opts.chrome) {
|
|
232
|
+
tooltip = createTooltipPresenter({ id: null });
|
|
233
|
+
const tip = tooltip;
|
|
234
|
+
cleanups.push(() => tip.dispose());
|
|
235
|
+
const viewcube = attachViewcubeControls(viewer, { stage: container });
|
|
236
|
+
cleanups.push(() => viewcube.detach());
|
|
237
|
+
toolbar = container.querySelector("#viewbar");
|
|
238
|
+
const style = viewStyle = attachViewStyleControls(viewer, { stage: container, toolbar, anchor: viewcube.element }, { tooltip });
|
|
239
|
+
cleanups.push(() => style.detach());
|
|
240
|
+
cleanups.push(observeViewbarClear(container, toolbar));
|
|
241
|
+
const chrome = attachViewerControls(viewer, elements.chrome ?? {}, { tooltip });
|
|
242
|
+
cleanups.push(() => chrome.detach());
|
|
243
|
+
}
|
|
109
244
|
cleanups.push(viewer.onRenderModeChange(relay(modeListeners)));
|
|
110
245
|
cleanups.push(viewer.onEnvironmentChange(relay(envListeners)));
|
|
111
246
|
cleanups.push(viewer.onContextLost(relay(lostListeners)));
|
|
@@ -114,8 +249,9 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
114
249
|
live = { viewer, view };
|
|
115
250
|
applyState(carried);
|
|
116
251
|
if (!active) viewer.setActive(false);
|
|
117
|
-
|
|
118
|
-
|
|
252
|
+
const offRendered = viewer.onRendered(() => { offRendered(); resolveDrawn(); });
|
|
253
|
+
cleanups.push(offRendered);
|
|
254
|
+
if (plays) {
|
|
119
255
|
player = createPreviewPlayer(viewer, decoded.animation, { reducedMotion });
|
|
120
256
|
const own = player;
|
|
121
257
|
cleanups.push(() => own.dispose());
|
|
@@ -124,9 +260,11 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
124
260
|
const playButton = attachPreviewPlayButton(own, { toolbar, before: viewStyle.element }, { tooltip });
|
|
125
261
|
cleanups.push(() => playButton.detach());
|
|
126
262
|
}
|
|
263
|
+
} else if (turns) {
|
|
264
|
+
cleanups.push(startTurntable(viewer, container, opts.turntable));
|
|
127
265
|
}
|
|
128
266
|
});
|
|
129
|
-
ready.catch(
|
|
267
|
+
ready.catch(rejectDrawn); // the host awaits it; never an unhandled rejection
|
|
130
268
|
|
|
131
269
|
function getViewerState() {
|
|
132
270
|
if (!live) return { camera: null, projection: "perspective", renderMode: "cad", ...carried, cutaway: null };
|
|
@@ -148,9 +286,11 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
148
286
|
|
|
149
287
|
return {
|
|
150
288
|
ready,
|
|
289
|
+
drawn,
|
|
151
290
|
dispose() {
|
|
152
291
|
if (disposed) return;
|
|
153
292
|
disposed = true;
|
|
293
|
+
rejectDrawn(new Error("mountPreview: disposed before the preview was drawn")); // a no-op once drawn
|
|
154
294
|
live = null;
|
|
155
295
|
player = null; // `animation` answers null once disposed (the cleanup below disposes it)
|
|
156
296
|
const errors = [];
|
|
@@ -167,7 +307,7 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
167
307
|
},
|
|
168
308
|
getViewerState,
|
|
169
309
|
setViewerState(next) {
|
|
170
|
-
carried = next
|
|
310
|
+
carried = carry(next);
|
|
171
311
|
applyState(carried);
|
|
172
312
|
},
|
|
173
313
|
setActive(next) {
|
package/src/framework/viewer.js
CHANGED
|
@@ -163,7 +163,22 @@ export function captureCurrentFromScene(
|
|
|
163
163
|
}
|
|
164
164
|
}
|
|
165
165
|
|
|
166
|
-
|
|
166
|
+
// Options (all default to today's viewer): `transparent` clears the canvas to
|
|
167
|
+
// alpha 0 in CAD mode (no theme background, no grid) so the host page shows
|
|
168
|
+
// through — realistic mode keeps its environment backdrop; `contactShadow`
|
|
169
|
+
// draws the product shot's soft contact shadow (contact-shadow.js, the mask
|
|
170
|
+
// captures use) under the part in CAD mode, on the live scene; `zoom` / `pan`
|
|
171
|
+
// false turn those OrbitControls gestures off (a wheel then scrolls the page).
|
|
172
|
+
// `frameFill` (0 < f <= 1) changes how frameTo frames: the framed assembly's
|
|
173
|
+
// posed bounding box, PROJECTED from the kept view direction, spans that
|
|
174
|
+
// fraction of the viewport along its limiting axis, centred on the horizontal
|
|
175
|
+
// vertex centroid; `frameSwept` frames the model swept a full turn about the
|
|
176
|
+
// vertical instead (a turntable never clips).
|
|
177
|
+
// Unset keeps the fixed r·2.6 + 6 distance. mountPreview's presentation
|
|
178
|
+
// options are the caller.
|
|
179
|
+
export function createViewer(container, part, {
|
|
180
|
+
transparent = false, contactShadow = false, zoom = true, pan = true, frameFill = null, frameSwept = false,
|
|
181
|
+
} = {}) {
|
|
167
182
|
const names = Object.keys(part.parts);
|
|
168
183
|
// Membership for host-supplied names. subMesh, cadMats and part.parts are
|
|
169
184
|
// plain objects, so "toString" or "constructor" would read as present on
|
|
@@ -172,7 +187,10 @@ export function createViewer(container, part) {
|
|
|
172
187
|
const has = (name) => nameSet.has(name);
|
|
173
188
|
|
|
174
189
|
// --- renderer / scene / camera --------------------------------------------
|
|
175
|
-
const renderer = new THREE.WebGLRenderer(
|
|
190
|
+
const renderer = new THREE.WebGLRenderer(transparent
|
|
191
|
+
? { antialias: true, stencil: true, alpha: true }
|
|
192
|
+
: { antialias: true, stencil: true });
|
|
193
|
+
if (transparent) renderer.setClearColor(0x000000, 0);
|
|
176
194
|
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
177
195
|
container.appendChild(renderer.domElement);
|
|
178
196
|
|
|
@@ -188,7 +206,10 @@ export function createViewer(container, part) {
|
|
|
188
206
|
dark: { bg: part.meta?.background ?? CAD_DARK_THEME.bg, grid: [0x2c333d, 0x222831], line: CAD_DARK_THEME.line },
|
|
189
207
|
light: { bg: CAD_LIGHT_THEME.bg, grid: [0xc4ccd6, 0xd6dce4], line: CAD_LIGHT_THEME.line },
|
|
190
208
|
};
|
|
191
|
-
|
|
209
|
+
// CAD mode's backdrop and grid; a transparent viewer has neither.
|
|
210
|
+
const cadBackground = (mode) => (transparent ? null : new THREE.Color(THEME[mode].bg));
|
|
211
|
+
const cadGrid = !transparent;
|
|
212
|
+
scene.background = cadBackground("dark");
|
|
192
213
|
|
|
193
214
|
let currentTheme = "dark";
|
|
194
215
|
const themeListeners = new Set();
|
|
@@ -216,6 +237,8 @@ export function createViewer(container, part) {
|
|
|
216
237
|
|
|
217
238
|
const controls = new OrbitControls(activeCamera, renderer.domElement);
|
|
218
239
|
controls.enableDamping = true;
|
|
240
|
+
controls.enableZoom = zoom !== false;
|
|
241
|
+
controls.enablePan = pan !== false;
|
|
219
242
|
// A finger a host gesture swallowed would otherwise stay "down" forever in
|
|
220
243
|
// whatever it went down on — the controls (every later drag zooms), the view
|
|
221
244
|
// cube, the cutaway handles, measure mode — so the guard watches the whole
|
|
@@ -253,6 +276,7 @@ export function createViewer(container, part) {
|
|
|
253
276
|
const GRID_SIZE = 300, GRID_DIVS = 30;
|
|
254
277
|
let floorY = 0; // world Y of the grid plane; set to the part's bbox bottom in frameTo
|
|
255
278
|
let grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, THEME.dark.grid[0], THEME.dark.grid[1]);
|
|
279
|
+
grid.visible = cadGrid;
|
|
256
280
|
scene.add(grid);
|
|
257
281
|
|
|
258
282
|
// --- material + part groups -----------------------------------------------
|
|
@@ -626,6 +650,8 @@ export function createViewer(container, part) {
|
|
|
626
650
|
// bigger half of the answer the way the grid is in CAD: without it the far
|
|
627
651
|
// plane cuts the disc's back edge off.
|
|
628
652
|
if (realisticRig) _depthBounds.union(_groundBounds.setFromObject(realisticRig.ground));
|
|
653
|
+
// The CAD contact shadow reaches past the part by its blur margin.
|
|
654
|
+
if (liveShadow) _depthBounds.union(_groundBounds.setFromObject(liveShadow));
|
|
629
655
|
if (_depthBounds.isEmpty()) return null;
|
|
630
656
|
return {
|
|
631
657
|
center: _depthBounds.getCenter(_depthCenter).toArray(),
|
|
@@ -962,6 +988,53 @@ export function createViewer(container, part) {
|
|
|
962
988
|
// contact shadow: low-res while the plane is dragged, full once it settles —
|
|
963
989
|
// the same schedule as a moving sub-part.
|
|
964
990
|
cutaway.onChange?.(() => { if (realisticRig) shadowMovedAt = performance.now(); });
|
|
991
|
+
|
|
992
|
+
// --- the CAD contact shadow (the `contactShadow` option) -------------------
|
|
993
|
+
// The product shot's shadow (renderStyles' thumbnail style), on the live
|
|
994
|
+
// scene: the same CPU mask the captures cast, as a black alpha-mapped plane a
|
|
995
|
+
// hair under the part. CAD mode only — realistic brings its own ground and
|
|
996
|
+
// shadow. Computed ONCE per assembly — on showAssembly, from the pose on
|
|
997
|
+
// screen then (a preview's meshes as the file stores them — the rest pose,
|
|
998
|
+
// before its player applies a first pose) — and again on entering CAD. Never
|
|
999
|
+
// for animation frames: the mask is a CPU rasterization plus a new texture,
|
|
1000
|
+
// and rebuilding it a few times a second while a preview played stalled the
|
|
1001
|
+
// host page's main thread (a landing page's typing slowed ~25×). So a moving
|
|
1002
|
+
// sub-part leaves its rest-pose shadow behind — accepted. A turntable needs no
|
|
1003
|
+
// rebuild: it moves the camera, never a caster or the ground.
|
|
1004
|
+
const LIVE_SHADOW = RENDER_STYLES.thumbnail.shadow;
|
|
1005
|
+
let liveShadow = null;
|
|
1006
|
+
function disposeLiveShadow() {
|
|
1007
|
+
if (!liveShadow) return;
|
|
1008
|
+
scene.remove(liveShadow);
|
|
1009
|
+
liveShadow.geometry.dispose();
|
|
1010
|
+
liveShadow.material.alphaMap.dispose();
|
|
1011
|
+
liveShadow.material.dispose();
|
|
1012
|
+
liveShadow = null;
|
|
1013
|
+
}
|
|
1014
|
+
const _liveShadowV = new THREE.Vector3();
|
|
1015
|
+
function rebuildLiveShadow() {
|
|
1016
|
+
disposeLiveShadow();
|
|
1017
|
+
if (!contactShadow || renderMode !== "cad") return;
|
|
1018
|
+
try {
|
|
1019
|
+
const bounds = getVisibleWorldBounds();
|
|
1020
|
+
if (bounds.isEmpty()) return;
|
|
1021
|
+
const box = { min: bounds.min.toArray(), max: bounds.max.toArray() };
|
|
1022
|
+
const meshes = castersNow().filter((m) => m.geometry?.attributes?.position).map((mesh) => {
|
|
1023
|
+
mesh.updateWorldMatrix(true, false);
|
|
1024
|
+
const pos = mesh.geometry.attributes.position;
|
|
1025
|
+
const positions = new Float32Array(pos.count * 3);
|
|
1026
|
+
for (let i = 0; i < pos.count; i++) _liveShadowV.fromBufferAttribute(pos, i).applyMatrix4(mesh.matrixWorld).toArray(positions, i * 3);
|
|
1027
|
+
return { positions, indices: mesh.geometry.index?.array ?? null };
|
|
1028
|
+
});
|
|
1029
|
+
const mask = contactShadowMask(meshes, box, LIVE_SHADOW);
|
|
1030
|
+
if (!mask) return;
|
|
1031
|
+
liveShadow = buildContactShadowPlane(mask, LIVE_SHADOW);
|
|
1032
|
+
scene.add(liveShadow);
|
|
1033
|
+
requestRender();
|
|
1034
|
+
} catch (e) {
|
|
1035
|
+
console.warn("partforge: contact shadow failed", e);
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
965
1038
|
cutaway.onChange?.(() => requestRender());
|
|
966
1039
|
|
|
967
1040
|
const livePixelRatio = (mode) =>
|
|
@@ -996,6 +1069,7 @@ export function createViewer(container, part) {
|
|
|
996
1069
|
scene.backgroundRotation.set(0, rig.rotationY ?? 0, 0);
|
|
997
1070
|
scene.environmentRotation.set(0, rig.rotationY ?? 0, 0);
|
|
998
1071
|
scene.add(rig.ground, rig.shadow.group, ...(rig.lights ?? []));
|
|
1072
|
+
disposeLiveShadow();
|
|
999
1073
|
for (const l of Object.values(liveLights)) l.visible = false;
|
|
1000
1074
|
grid.visible = false;
|
|
1001
1075
|
renderer.toneMapping = THREE.NeutralToneMapping;
|
|
@@ -1030,16 +1104,17 @@ export function createViewer(container, part) {
|
|
|
1030
1104
|
shadowMovedAt = null;
|
|
1031
1105
|
attempt(() => {
|
|
1032
1106
|
scene.environment = null;
|
|
1033
|
-
scene.background =
|
|
1107
|
+
scene.background = cadBackground(currentTheme);
|
|
1034
1108
|
scene.backgroundBlurriness = 0;
|
|
1035
1109
|
scene.backgroundIntensity = 1;
|
|
1036
1110
|
scene.backgroundRotation.set(0, 0, 0);
|
|
1037
1111
|
scene.environmentRotation.set(0, 0, 0);
|
|
1038
1112
|
});
|
|
1039
1113
|
attempt(() => { for (const l of Object.values(liveLights)) l.visible = true; });
|
|
1040
|
-
attempt(() => { grid.visible =
|
|
1114
|
+
attempt(() => { grid.visible = cadGrid; });
|
|
1041
1115
|
for (const n of names) attempt(() => { if (baseMats[n] !== cadMats[n]) rebaseSubMaterial(n, cadMats[n]); else applySubOpacity(n); });
|
|
1042
1116
|
if (live) attempt(() => applyPixelRatio("cad"));
|
|
1117
|
+
if (contactShadow) attempt(rebuildLiveShadow);
|
|
1043
1118
|
attempt(refreshFeatureLines);
|
|
1044
1119
|
if (live) invalidateMaterialThumbnails();
|
|
1045
1120
|
if (firstError) throw firstError;
|
|
@@ -1242,6 +1317,10 @@ export function createViewer(container, part) {
|
|
|
1242
1317
|
// (setActive(false)) automatically halts playback too: no loop, no ticks.
|
|
1243
1318
|
const frameListeners = new Set();
|
|
1244
1319
|
function onFrame(cb) { frameListeners.add(cb); return () => frameListeners.delete(cb); }
|
|
1320
|
+
// After a frame has actually DRAWN to the canvas (the loop renders on demand,
|
|
1321
|
+
// so most frames draw nothing and call nobody). mountPreview's `drawn`.
|
|
1322
|
+
const renderedListeners = new Set();
|
|
1323
|
+
function onRendered(cb) { renderedListeners.add(cb); return () => renderedListeners.delete(cb); }
|
|
1245
1324
|
|
|
1246
1325
|
// Fired at the end of every showAssembly, for hosts that need to react to
|
|
1247
1326
|
// which sub-parts are visible (e.g. re-deriving a view style thumbnail).
|
|
@@ -1864,6 +1943,11 @@ export function createViewer(container, part) {
|
|
|
1864
1943
|
}
|
|
1865
1944
|
if (_box.isEmpty()) return;
|
|
1866
1945
|
const center = _box.getCenter(new THREE.Vector3());
|
|
1946
|
+
// A fill framing centres on the geometry, not its box: the horizontal
|
|
1947
|
+
// vertex centroid (model x/y — the pivot stands model Z up) with the box's
|
|
1948
|
+
// vertical centre. A lopsided part (a three-blade propeller) then turns
|
|
1949
|
+
// about its hub instead of orbiting the middle of its box.
|
|
1950
|
+
if (frameFill) horizontalCentroid(visibleNames, center);
|
|
1867
1951
|
partsGroup.position.copy(center).multiplyScalar(-1); // centre assembly on the pivot
|
|
1868
1952
|
const size = _box.getSize(new THREE.Vector3());
|
|
1869
1953
|
// Drop the grid to the bottom of the bounding box (model Z -> world Y), so it reads
|
|
@@ -1871,6 +1955,7 @@ export function createViewer(container, part) {
|
|
|
1871
1955
|
floorY = -size.z / 2;
|
|
1872
1956
|
grid.position.y = floorY;
|
|
1873
1957
|
const r = Math.max(size.x, size.y, size.z) || 12;
|
|
1958
|
+
const fill = fillFraming(_box, visibleNames, center);
|
|
1874
1959
|
// Keep the VIEW DIRECTION (target -> camera), not the camera's direction
|
|
1875
1960
|
// from the origin: after a pan the target is off the origin, and scaling
|
|
1876
1961
|
// the position about the origin would turn the view — which, in an ortho
|
|
@@ -1880,11 +1965,138 @@ export function createViewer(container, part) {
|
|
|
1880
1965
|
_frameDir.copy(activeCamera.position).sub(controls.target);
|
|
1881
1966
|
if (_frameDir.lengthSq() === 0) _frameDir.copy(activeCamera.position);
|
|
1882
1967
|
if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
|
|
1883
|
-
|
|
1968
|
+
_frameDir.normalize();
|
|
1969
|
+
const filled = fill?.(_frameDir);
|
|
1970
|
+
activeCamera.position.copy(_frameDir).multiplyScalar(filled ? filled.distance : r * 2.6 + 6);
|
|
1884
1971
|
controls.target.set(0, 0, 0);
|
|
1885
1972
|
// Framing under ortho is a frustum, not a distance — without this the
|
|
1886
|
-
// reframe button moves the camera and nothing visibly changes.
|
|
1887
|
-
|
|
1973
|
+
// reframe button moves the camera and nothing visibly changes. A fill
|
|
1974
|
+
// framing sizes the frustum to the projected box directly (the sync
|
|
1975
|
+
// matches size at the surface under the screen centre, not the extent).
|
|
1976
|
+
if (projectionMode === "orthographic") {
|
|
1977
|
+
if (filled) {
|
|
1978
|
+
applyOrthoFrustum({ halfH: filled.orthoHalfH, halfW: filled.orthoHalfH * filled.aspect });
|
|
1979
|
+
orthoCamera.zoom = 1;
|
|
1980
|
+
orthoCamera.updateProjectionMatrix();
|
|
1981
|
+
} else {
|
|
1982
|
+
syncOrthoToPerspectiveFraming();
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
// The `frameFill` framing. `size` is the posed box in the model frame; the
|
|
1988
|
+
// assembly is centred on the pivot, so its world corners are the half-extents
|
|
1989
|
+
// stood up by the pivot's rotation. With `frameSwept` the points are the
|
|
1990
|
+
// model swept about the vertical through the box centre — two circles at the
|
|
1991
|
+
// box's top and bottom whose radius is the farthest posed VERTEX from that
|
|
1992
|
+
// axis (nudged out so the polygon contains the circle). Not the box's
|
|
1993
|
+
// corners: those sit ~√2 farther out than a round part's rim, which framed a
|
|
1994
|
+
// turning gear at little more than half the asked-for width. Returns a function of the unit view
|
|
1995
|
+
// direction (target → camera) giving `{ distance, orthoHalfH, aspect }`:
|
|
1996
|
+
// - perspective: the distance, along that direction to the origin target,
|
|
1997
|
+
// at which the points' projected extent — the larger of (projected width /
|
|
1998
|
+
// viewport width, projected height / viewport height) — equals frameFill.
|
|
1999
|
+
// The extent only shrinks as the camera backs off, so it is bisected.
|
|
2000
|
+
// - orthographic: the half-height that gives the same extent.
|
|
2001
|
+
// Null when unset, or when the container has no size yet (a hidden or
|
|
2002
|
+
// unlaid-out stage): frameTo then falls back to the fixed distance, and the
|
|
2003
|
+
// next reframe fills.
|
|
2004
|
+
const SWEEP_SEGMENTS = 48;
|
|
2005
|
+
const _sweepV = new THREE.Vector3();
|
|
2006
|
+
// The farthest horizontal distance of any posed vertex of `names` from the
|
|
2007
|
+
// vertical through `center` (model frame), in world units. Reads positions
|
|
2008
|
+
// through BufferAttribute accessors, so normalized/quantized data is
|
|
2009
|
+
// dequantized.
|
|
2010
|
+
function sweepRadius(names, center) {
|
|
2011
|
+
let max = 0;
|
|
2012
|
+
for (const name of names) {
|
|
2013
|
+
const pos = subCache[name]?.getAttribute("position");
|
|
2014
|
+
if (!pos) continue;
|
|
2015
|
+
const pose = subMesh[name].matrix;
|
|
2016
|
+
for (let i = 0; i < pos.count; i++) {
|
|
2017
|
+
_sweepV.fromBufferAttribute(pos, i).applyMatrix4(pose).sub(center).applyQuaternion(pivot.quaternion);
|
|
2018
|
+
const d = Math.hypot(_sweepV.x, _sweepV.z);
|
|
2019
|
+
if (d > max) max = d;
|
|
2020
|
+
}
|
|
2021
|
+
}
|
|
2022
|
+
return max;
|
|
2023
|
+
}
|
|
2024
|
+
// Overwrite `center`'s model x/y with the mean posed vertex position of
|
|
2025
|
+
// `names` (z untouched). A plain vertex mean: preview meshes are close enough
|
|
2026
|
+
// to uniformly tessellated for it to land on the hub of a lopsided part.
|
|
2027
|
+
function horizontalCentroid(names, center) {
|
|
2028
|
+
let sx = 0, sy = 0, n = 0;
|
|
2029
|
+
for (const name of names) {
|
|
2030
|
+
const pos = subCache[name]?.getAttribute("position");
|
|
2031
|
+
if (!pos) continue;
|
|
2032
|
+
const pose = subMesh[name].matrix;
|
|
2033
|
+
for (let i = 0; i < pos.count; i++) {
|
|
2034
|
+
_sweepV.fromBufferAttribute(pos, i).applyMatrix4(pose);
|
|
2035
|
+
sx += _sweepV.x; sy += _sweepV.y; n++;
|
|
2036
|
+
}
|
|
2037
|
+
}
|
|
2038
|
+
if (n > 0) center.set(sx / n, sy / n, center.z);
|
|
2039
|
+
}
|
|
2040
|
+
function fillFraming(box, names, center) {
|
|
2041
|
+
if (!frameFill) return null;
|
|
2042
|
+
const w = container.clientWidth, h = container.clientHeight;
|
|
2043
|
+
if (!(w > 0 && h > 0)) return null;
|
|
2044
|
+
const aspect = w / h;
|
|
2045
|
+
pivot.updateMatrix();
|
|
2046
|
+
// The box corners about the framing centre (no longer the box's own centre).
|
|
2047
|
+
let points = [];
|
|
2048
|
+
for (const x of [box.min.x, box.max.x]) for (const y of [box.min.y, box.max.y]) for (const z of [box.min.z, box.max.z]) {
|
|
2049
|
+
points.push(new THREE.Vector3(x, y, z).sub(center).applyQuaternion(pivot.quaternion));
|
|
2050
|
+
}
|
|
2051
|
+
if (frameSwept) {
|
|
2052
|
+
const radius = sweepRadius(names, center) / Math.cos(Math.PI / SWEEP_SEGMENTS);
|
|
2053
|
+
const ys = [Math.min(...points.map((p) => p.y)), Math.max(...points.map((p) => p.y))];
|
|
2054
|
+
points = [];
|
|
2055
|
+
for (let k = 0; k < SWEEP_SEGMENTS; k++) {
|
|
2056
|
+
const a = (2 * Math.PI * k) / SWEEP_SEGMENTS;
|
|
2057
|
+
for (const y of ys) points.push(new THREE.Vector3(radius * Math.cos(a), y, radius * Math.sin(a)));
|
|
2058
|
+
}
|
|
2059
|
+
}
|
|
2060
|
+
if (points.every((p) => p.lengthSq() === 0)) return null;
|
|
2061
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
|
|
2062
|
+
const tanH = tanV * aspect;
|
|
2063
|
+
return (dir) => {
|
|
2064
|
+
// The camera's basis for this view direction, `up` as the controls keep it.
|
|
2065
|
+
const forward = dir.clone().negate();
|
|
2066
|
+
const right = new THREE.Vector3().crossVectors(forward, activeCamera.up);
|
|
2067
|
+
if (right.lengthSq() < 1e-12) right.set(1, 0, 0); // looking straight along up
|
|
2068
|
+
right.normalize();
|
|
2069
|
+
const upAxis = new THREE.Vector3().crossVectors(right, forward);
|
|
2070
|
+
const xs = points.map((p) => p.dot(right)), ys = points.map((p) => p.dot(upAxis)), along = points.map((p) => p.dot(dir));
|
|
2071
|
+
// `fill` is the projected SPAN: the larger of (projected width / viewport
|
|
2072
|
+
// width, projected height / viewport height). The framing centre sits at
|
|
2073
|
+
// the screen centre, though, and a lopsided part reaches farther one way
|
|
2074
|
+
// than the other — so the camera also backs off until nothing leaves the
|
|
2075
|
+
// viewport (|NDC| <= 1 on both axes). For a part about as symmetric as
|
|
2076
|
+
// its centre, the span decides alone.
|
|
2077
|
+
const span = (v) => Math.max(...v) - Math.min(...v);
|
|
2078
|
+
const reach = (v) => Math.max(...v.map(Math.abs));
|
|
2079
|
+
const orthoHalfH = Math.max(span(xs) / (2 * frameFill * aspect), span(ys) / (2 * frameFill), reach(xs) / aspect, reach(ys));
|
|
2080
|
+
// How far over budget the projection is at distance d (<= 1 fits).
|
|
2081
|
+
const over = (d) => {
|
|
2082
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, r = 0;
|
|
2083
|
+
for (let i = 0; i < points.length; i++) {
|
|
2084
|
+
const z = d - along[i]; // depth in front of the camera
|
|
2085
|
+
const nx = xs[i] / (z * tanH), ny = ys[i] / (z * tanV);
|
|
2086
|
+
x0 = Math.min(x0, nx); x1 = Math.max(x1, nx); y0 = Math.min(y0, ny); y1 = Math.max(y1, ny);
|
|
2087
|
+
r = Math.max(r, Math.abs(nx), Math.abs(ny));
|
|
2088
|
+
}
|
|
2089
|
+
return Math.max(Math.max(x1 - x0, y1 - y0) / (2 * frameFill), r); // NDC spans 2 per axis
|
|
2090
|
+
};
|
|
2091
|
+
const nearest = Math.max(...along);
|
|
2092
|
+
let lo = nearest + 1e-6 * (1 + Math.abs(nearest)), hi = Math.max(lo * 2, 1);
|
|
2093
|
+
while (over(hi) > 1 && hi < 1e9) hi *= 2;
|
|
2094
|
+
for (let i = 0; i < 60; i++) {
|
|
2095
|
+
const mid = (lo + hi) / 2;
|
|
2096
|
+
if (over(mid) > 1) lo = mid; else hi = mid;
|
|
2097
|
+
}
|
|
2098
|
+
return { distance: hi, orthoHalfH, aspect };
|
|
2099
|
+
};
|
|
1888
2100
|
}
|
|
1889
2101
|
|
|
1890
2102
|
// Show exactly the named sub-parts (from the cache). When `frame` is set, also
|
|
@@ -1906,6 +2118,7 @@ export function createViewer(container, part) {
|
|
|
1906
2118
|
if (frame) frameTo(visibleNames);
|
|
1907
2119
|
cutaway.setVisible(effectiveVisible());
|
|
1908
2120
|
placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
|
|
2121
|
+
if (contactShadow) rebuildLiveShadow(); // CAD only (a no-op in realistic)
|
|
1909
2122
|
for (const cb of [...assemblyListeners]) {
|
|
1910
2123
|
try { cb(); } catch (e) { console.warn("partforge: assembly listener failed", e); }
|
|
1911
2124
|
}
|
|
@@ -1949,11 +2162,11 @@ export function createViewer(container, part) {
|
|
|
1949
2162
|
const t = THEME[mode] ?? THEME.dark;
|
|
1950
2163
|
// Realistic mode's backdrop belongs to the environment and its grid is off;
|
|
1951
2164
|
// enterCad puts the theme's background back on the way out.
|
|
1952
|
-
if (renderMode === "cad") scene.background =
|
|
2165
|
+
if (renderMode === "cad") scene.background = cadBackground(THEME[mode] ? mode : "dark");
|
|
1953
2166
|
scene.remove(grid);
|
|
1954
2167
|
grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, t.grid[0], t.grid[1]);
|
|
1955
2168
|
grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
|
|
1956
|
-
grid.visible = renderMode === "cad";
|
|
2169
|
+
grid.visible = renderMode === "cad" && cadGrid;
|
|
1957
2170
|
scene.add(grid);
|
|
1958
2171
|
lineMaterial.color.set(t.line);
|
|
1959
2172
|
for (const m of fadeLineMats.values()) m.color.set(t.line); // clones follow the theme
|
|
@@ -2756,6 +2969,9 @@ export function createViewer(container, part) {
|
|
|
2756
2969
|
}
|
|
2757
2970
|
renderWithBackdrop(scene, activeCamera);
|
|
2758
2971
|
cutaway.renderOverlay(renderer, activeCamera);
|
|
2972
|
+
for (const cb of [...renderedListeners]) {
|
|
2973
|
+
try { cb(); } catch (e) { console.warn("partforge: rendered listener failed", e); }
|
|
2974
|
+
}
|
|
2759
2975
|
}
|
|
2760
2976
|
renderer.setAnimationLoop(renderFrame);
|
|
2761
2977
|
|
|
@@ -3032,6 +3248,7 @@ export function createViewer(container, part) {
|
|
|
3032
3248
|
controls.removeEventListener("start", onControlsStart);
|
|
3033
3249
|
cameraStartListeners.clear();
|
|
3034
3250
|
frameListeners.clear();
|
|
3251
|
+
renderedListeners.clear();
|
|
3035
3252
|
assemblyListeners.clear();
|
|
3036
3253
|
themeListeners.clear();
|
|
3037
3254
|
projectionListeners.clear();
|
|
@@ -3078,6 +3295,7 @@ export function createViewer(container, part) {
|
|
|
3078
3295
|
fadeMats.clear();
|
|
3079
3296
|
fadeLineMats.clear();
|
|
3080
3297
|
disposeRealistic();
|
|
3298
|
+
disposeLiveShadow();
|
|
3081
3299
|
material.dispose();
|
|
3082
3300
|
lineMaterial.dispose();
|
|
3083
3301
|
grid.geometry.dispose();
|
|
@@ -3113,6 +3331,7 @@ export function createViewer(container, part) {
|
|
|
3113
3331
|
renderMaterialThumbnail,
|
|
3114
3332
|
invalidateMaterialThumbnails,
|
|
3115
3333
|
onFrame,
|
|
3334
|
+
onRendered,
|
|
3116
3335
|
onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
|
|
3117
3336
|
tweenCameraTo,
|
|
3118
3337
|
getCameraCue,
|
package/types/index.d.ts
CHANGED
|
@@ -798,7 +798,7 @@ export interface PartRuntime {
|
|
|
798
798
|
/** Mount a full parametric-part app from a `PartDefinition`. */
|
|
799
799
|
export function mount(part: PartDefinition, options: MountOptions): PartRuntime;
|
|
800
800
|
|
|
801
|
-
/** Options for `mountPreview`. */
|
|
801
|
+
/** Options for `mountPreview`. Any other key throws a `TypeError`. */
|
|
802
802
|
export interface MountPreviewOptions {
|
|
803
803
|
/** A GLB from `partforge/gltf`'s `encodePreviewGlb`, or a runtime's `exportPreview()`. */
|
|
804
804
|
glb: Uint8Array | ArrayBuffer;
|
|
@@ -808,12 +808,50 @@ export interface MountPreviewOptions {
|
|
|
808
808
|
viewerState?: ViewerState | null;
|
|
809
809
|
/** Play the file's baked animation once drawn (default true). The editor's own boot preview goes through mount(), not here. */
|
|
810
810
|
animate?: boolean;
|
|
811
|
+
/** `false` (0.168.0+): no view cube, `#viewbar` controls, view style, tooltips, play button or `elements.chrome` wiring. Default true. */
|
|
812
|
+
chrome?: boolean;
|
|
813
|
+
/** `"transparent"` (0.168.0+): alpha-0 clear, no theme background, no grid. CAD mode only — realistic mode draws its own environment backdrop. Default `"theme"`. */
|
|
814
|
+
background?: "theme" | "transparent";
|
|
815
|
+
/** `true` (0.168.0+): a soft contact shadow under the part, cast once at showAssembly from the meshes as the file stores them (the rest pose, before a player applies its first pose); a playing animation does not move it. CAD mode only — realistic mode draws its own ground and shadow. Default false. */
|
|
816
|
+
contactShadow?: boolean;
|
|
817
|
+
/** `false` (0.168.0+): no wheel/pinch zoom (a wheel scrolls the page). Default true. */
|
|
818
|
+
zoom?: boolean;
|
|
819
|
+
/** `false` (0.168.0+): no pan. Default true. */
|
|
820
|
+
pan?: boolean;
|
|
821
|
+
/**
|
|
822
|
+
* Degrees per second (0.168.0+; default 0, off): a file with no playing animation
|
|
823
|
+
* turns about the vertical. Never under reduced motion; the first camera grab
|
|
824
|
+
* (a canvas drag or the view cube) stops it for good.
|
|
825
|
+
*/
|
|
826
|
+
turntable?: number;
|
|
827
|
+
/**
|
|
828
|
+
* 0 < fill <= 1 (0.168.0+): the opening framing (and the viewer's reframe) puts the
|
|
829
|
+
* camera, along the opening view direction, so the model's bounding box as projected
|
|
830
|
+
* on screen spans this fraction of the viewport along its limiting axis. With a
|
|
831
|
+
* `turntable` (and no baked animation) the model swept a full turn is framed — its
|
|
832
|
+
* farthest vertex from the vertical axis, over the box's height — so the turn never
|
|
833
|
+
* clips and a round part still fills. With `fill` the model is centred on its
|
|
834
|
+
* horizontal vertex centroid (a lopsided part turns about its hub), and nothing
|
|
835
|
+
* leaves the viewport; a baked animation that moves parts outside the box may. Unset, the
|
|
836
|
+
* fixed framing distance.
|
|
837
|
+
*/
|
|
838
|
+
fill?: number;
|
|
839
|
+
/** `"default"` (0.168.0+): drop the camera and projection of `viewerState` and `setViewerState`, so the mount keeps the file's own framing; their render mode and environment still apply. Default `"carry"`. */
|
|
840
|
+
camera?: "carry" | "default";
|
|
841
|
+
/** The opening render mode (0.168.0+): applied at mount, over any carried render mode; afterwards `setViewerState`, `renderMode.set` and the view style control change it as usual. Unset, the carried state decides. */
|
|
842
|
+
renderMode?: RenderMode;
|
|
811
843
|
}
|
|
812
844
|
|
|
813
845
|
/** The handle `mountPreview()` returns. Every method is safe before `ready` settles. */
|
|
814
846
|
export interface PreviewRuntime {
|
|
815
847
|
/** Resolves once the GLB is drawn; rejects when it will not decode or holds no meshes. */
|
|
816
848
|
ready: Promise<void>;
|
|
849
|
+
/**
|
|
850
|
+
* Resolves after the first frame that includes the meshes has rendered (0.168.0+) —
|
|
851
|
+
* for cross-fading from a still image. Rejects with `ready`'s error when the file
|
|
852
|
+
* will not decode, or when the handle is disposed first. Waits while parked.
|
|
853
|
+
*/
|
|
854
|
+
drawn: Promise<void>;
|
|
817
855
|
/** Full teardown. Idempotent. */
|
|
818
856
|
dispose(): void;
|
|
819
857
|
/** The view the file was written for (`null` until drawn, or when the file names none). */
|