partforge 0.167.0 → 0.169.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 +39 -0
- package/package.json +1 -1
- package/src/framework/mount-preview.js +265 -19
- package/src/framework/preview-play-button.js +4 -3
- package/src/framework/preview-player.js +38 -8
- package/src/framework/viewer.js +332 -11
- package/types/index.d.ts +67 -1
package/README.md
CHANGED
|
@@ -187,11 +187,50 @@ 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, and it
|
|
202
|
+
turns about its hub; a model that is not turning has its on-screen outline centred, since 0.169.0), `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), and `autostart: false`
|
|
206
|
+
(0.169.0+: the animation or turntable waits at its first frame until
|
|
207
|
+
`handle.motion.start()`). An unknown option key
|
|
208
|
+
throws. A baked animation always plays as authored — it
|
|
209
|
+
loops, or plays once and holds its end pose. Every handle carries `drawn`, which
|
|
210
|
+
resolves after the first frame showing the meshes, so a host can cross-fade from
|
|
211
|
+
a still image.
|
|
212
|
+
|
|
213
|
+
`handle.motion` (0.169.0+; `null` before `ready` and once disposed) drives that
|
|
214
|
+
motion: `start()` begins a waiting one (idempotent), `restart()` puts the model
|
|
215
|
+
back as it opened — the opening camera framing, the animation from its start or
|
|
216
|
+
the turntable turning again — and starts it, `state` is `"waiting"`,
|
|
217
|
+
`"playing"`, `"stopped"` (a drag stopped the turntable, or nothing moves) or
|
|
218
|
+
`"finished"` (a play-once animation ended), and `onChange(cb)` hears each change.
|
|
219
|
+
Under `prefers-reduced-motion` nothing moves and `restart()` only resets the
|
|
220
|
+
camera; a turntable mount is framed for its full turn either way.
|
|
221
|
+
|
|
190
222
|
```js
|
|
191
223
|
const glb = await runtime.exportPreview(); // Uint8Array: the view on screen
|
|
192
224
|
mount(part, { createWorker, elements, preview: glb }); // drawn at once, replaced by the build
|
|
193
225
|
const shown = mountPreview(stage, { glb }); // no kernel at all
|
|
194
226
|
await shown.ready;
|
|
227
|
+
const card = mountPreview(cardStage, { // a landing-page card
|
|
228
|
+
glb, chrome: false, background: "transparent", contactShadow: true,
|
|
229
|
+
zoom: false, pan: false, turntable: 14, fill: 0.8, camera: "default", renderMode: "cad",
|
|
230
|
+
});
|
|
231
|
+
await card.drawn; // the first frame is on the canvas
|
|
232
|
+
// With autostart: false the card waits at that frame: fade it in, then
|
|
233
|
+
// card.motion.start(); a "replay" button calls card.motion.restart().
|
|
195
234
|
```
|
|
196
235
|
|
|
197
236
|
Every `elements` entry defaults to the legacy global ID (`#app`, `#controls`,
|
package/package.json
CHANGED
|
@@ -30,6 +30,82 @@
|
|
|
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 (until
|
|
53
|
+
// `motion.restart()`). Dragging an animated model orbits the camera and the
|
|
54
|
+
// animation plays on.
|
|
55
|
+
// - `fill: <0 < f <= 1>` — the opening framing (and the viewer's reframe)
|
|
56
|
+
// places the camera, along the kept view direction, so the model's posed
|
|
57
|
+
// bounding box as projected on screen spans that fraction of the viewport
|
|
58
|
+
// along its limiting axis. A turning model is framed swept a full turn
|
|
59
|
+
// about the vertical (its farthest vertex from the axis), so the turntable
|
|
60
|
+
// never clips and a round part still fills, centred on its horizontal
|
|
61
|
+
// vertex centroid (a lopsided part turns about its hub). A model that is
|
|
62
|
+
// not turning (a still, or a playing animation — framed on its first
|
|
63
|
+
// pose) is fitted by its posed VERTICES and its silhouette centred on
|
|
64
|
+
// screen (0.169.0): the target moves in the screen plane so the projected
|
|
65
|
+
// outline's bounding box is centred, spanning `fill`. A baked animation
|
|
66
|
+
// that moves parts outside its first pose's outline can leave the frame.
|
|
67
|
+
// Unset, the viewer's fixed framing distance, as before.
|
|
68
|
+
// The swept framing follows the configuration, not reduced motion (0.169.0):
|
|
69
|
+
// a turntable mount is framed swept even when reduced motion keeps it still.
|
|
70
|
+
// - `camera: "default"` — the camera and projection of `viewerState` and of
|
|
71
|
+
// every setViewerState are dropped: the mount opens, and stays, at the
|
|
72
|
+
// file's own default framing. Their render mode and environment still apply.
|
|
73
|
+
// - `renderMode: "cad" | "realistic"` — the OPENING mode: applied at mount,
|
|
74
|
+
// over any carried render mode. After that it is an ordinary mode —
|
|
75
|
+
// setViewerState, renderMode.set and the view style control change it.
|
|
76
|
+
// Unset, the carried state decides, as before.
|
|
77
|
+
// - `autostart: false` (0.169.0; default true) — the motion (a playing
|
|
78
|
+
// animation, or the turntable) is set up but WAITS: the model is drawn at
|
|
79
|
+
// the animation's first pose (the t = 0 pose a started player draws first)
|
|
80
|
+
// and at turntable angle 0, and nothing moves until `handle.motion.start()`.
|
|
81
|
+
// `drawn` resolves on a frame showing exactly that pose, so a host can
|
|
82
|
+
// cross-fade from a still of it and then say go. A waiting animation's play
|
|
83
|
+
// button reads Play.
|
|
84
|
+
// Any other key throws (an option this version does not know is a host bug).
|
|
85
|
+
//
|
|
86
|
+
// `handle.motion` (0.169.0; null before ready and once disposed, present for
|
|
87
|
+
// every decoded file) drives the motion whatever `autostart` said:
|
|
88
|
+
// - `start()` begins a waiting motion. Idempotent; a no-op once started, under
|
|
89
|
+
// reduced motion, or with nothing to move.
|
|
90
|
+
// - `restart()` puts the model back as it opened and starts it: the camera
|
|
91
|
+
// returns to the opening framing (showAssembly's framing at mount — the
|
|
92
|
+
// file's default view direction at the fill distance, before any carried
|
|
93
|
+
// camera — so a visitor's orbit is undone), the animation replays from
|
|
94
|
+
// t = 0 (intro cue included) or the turntable turns again from there,
|
|
95
|
+
// re-armed so the next grab stops it again. Under reduced motion it only
|
|
96
|
+
// resets the camera (the pose already is t = 0).
|
|
97
|
+
// - `state` — "waiting" | "playing" | "stopped" (a turntable stopped by a
|
|
98
|
+
// grab, an animation paused from its button, reduced motion, or nothing to
|
|
99
|
+
// move) | "finished" (a play-once animation resting at its end pose).
|
|
100
|
+
// - `onChange(cb)` → unsubscribe: hears each state change.
|
|
101
|
+
// `handle.animation` is unchanged.
|
|
102
|
+
//
|
|
103
|
+
// `handle.drawn` resolves after the first frame that includes the meshes has
|
|
104
|
+
// rendered (so a host can cross-fade from a still image without a flash). It
|
|
105
|
+
// rejects with `ready`'s error when the file will not decode, and rejects if
|
|
106
|
+
// the handle is disposed before that frame. A parked preview draws nothing, so
|
|
107
|
+
// `drawn` waits for setActive(true).
|
|
108
|
+
//
|
|
33
109
|
// The decoder loads on demand (it carries the
|
|
34
110
|
// GLTFLoader and meshopt), so a bundle that never mounts a preview never ships it.
|
|
35
111
|
// Hence `ready`. Every handle method is safe before it resolves.
|
|
@@ -45,6 +121,84 @@ import { ENVIRONMENTS } from "./materials/environments.js";
|
|
|
45
121
|
import { createPreviewPlayer } from "./preview-player.js";
|
|
46
122
|
import { attachPreviewPlayButton } from "./preview-play-button.js";
|
|
47
123
|
|
|
124
|
+
// A value the option does not know is a host bug: say so at the call rather
|
|
125
|
+
// than quietly showing the default.
|
|
126
|
+
function oneOf(name, value, allowed, fallback) {
|
|
127
|
+
if (value === undefined) return fallback;
|
|
128
|
+
if (!allowed.includes(value)) throw new TypeError(`mountPreview: ${name} must be ${allowed.map((a) => JSON.stringify(a)).join(" or ")}`);
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
function flag(name, value, fallback) {
|
|
132
|
+
if (value === undefined) return fallback;
|
|
133
|
+
if (typeof value !== "boolean") throw new TypeError(`mountPreview: ${name} must be a boolean`);
|
|
134
|
+
return value;
|
|
135
|
+
}
|
|
136
|
+
const KNOWN_OPTIONS = new Set([
|
|
137
|
+
"glb", "elements", "viewerState", "animate",
|
|
138
|
+
"chrome", "background", "contactShadow", "zoom", "pan", "turntable", "fill", "camera", "renderMode",
|
|
139
|
+
"autostart",
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode, autostart }) {
|
|
143
|
+
if (fill !== undefined && !(typeof fill === "number" && fill > 0 && fill <= 1)) {
|
|
144
|
+
throw new TypeError("mountPreview: fill must be a number above 0 and at most 1");
|
|
145
|
+
}
|
|
146
|
+
if (turntable !== undefined && !(typeof turntable === "number" && Number.isFinite(turntable) && turntable >= 0)) {
|
|
147
|
+
throw new TypeError("mountPreview: turntable must be degrees per second, 0 or more");
|
|
148
|
+
}
|
|
149
|
+
return {
|
|
150
|
+
chrome: flag("chrome", chrome, true),
|
|
151
|
+
transparent: oneOf("background", background, ["theme", "transparent"], "theme") === "transparent",
|
|
152
|
+
contactShadow: flag("contactShadow", contactShadow, false),
|
|
153
|
+
zoom: flag("zoom", zoom, true),
|
|
154
|
+
pan: flag("pan", pan, true),
|
|
155
|
+
turntable: turntable ?? 0,
|
|
156
|
+
fill: fill ?? null,
|
|
157
|
+
defaultCamera: oneOf("camera", camera, ["carry", "default"], "carry") === "default",
|
|
158
|
+
renderMode: oneOf("renderMode", renderMode, ["cad", "realistic"], null),
|
|
159
|
+
autostart: flag("autostart", autostart, true),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Turn the camera about the vertical at `degPerSec` until the first camera grab.
|
|
164
|
+
// It turns through viewer.orbitBy, which itself announces a camera grab — so the
|
|
165
|
+
// turntable's own steps are told apart from the user's by a flag held across
|
|
166
|
+
// the call. It begins "waiting" (angle 0, nothing moves) until start(); the
|
|
167
|
+
// first grab — while waiting or turning — stops it for good; restart() re-arms
|
|
168
|
+
// it and turns again from wherever the camera is now (the host has just put it
|
|
169
|
+
// back). `onChange` hears every phase change.
|
|
170
|
+
function createTurntable(viewer, container, degPerSec, onChange) {
|
|
171
|
+
let turning = false, phase = "waiting", offFrame = null;
|
|
172
|
+
const set = (next) => { if (next !== phase) { phase = next; onChange(); } };
|
|
173
|
+
function step(dt) {
|
|
174
|
+
// orbitBy's rate: a drag the full stage height is a full turn.
|
|
175
|
+
const dx = ((degPerSec * dt) / 360) * Math.max(1, container.clientHeight || 1);
|
|
176
|
+
if (!(dx > 0)) return;
|
|
177
|
+
turning = true;
|
|
178
|
+
try { viewer.orbitBy(dx, 0); } finally { turning = false; }
|
|
179
|
+
}
|
|
180
|
+
function halt() { offFrame?.(); offFrame = null; }
|
|
181
|
+
const offGrab = viewer.onCameraStart(() => {
|
|
182
|
+
if (turning || phase === "stopped") return;
|
|
183
|
+
halt();
|
|
184
|
+
set("stopped");
|
|
185
|
+
});
|
|
186
|
+
return {
|
|
187
|
+
phase: () => phase,
|
|
188
|
+
start() {
|
|
189
|
+
if (phase !== "waiting") return;
|
|
190
|
+
offFrame = viewer.onFrame(step);
|
|
191
|
+
set("playing");
|
|
192
|
+
},
|
|
193
|
+
restart() {
|
|
194
|
+
halt();
|
|
195
|
+
offFrame = viewer.onFrame(step);
|
|
196
|
+
set("playing");
|
|
197
|
+
},
|
|
198
|
+
dispose() { halt(); offGrab(); },
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
48
202
|
function previewPart({ meshes, view, meta }) {
|
|
49
203
|
return {
|
|
50
204
|
meta: { title: "Preview", ...(meta ?? {}) },
|
|
@@ -65,24 +219,45 @@ const relay = (listeners) => (event) => {
|
|
|
65
219
|
}
|
|
66
220
|
};
|
|
67
221
|
|
|
68
|
-
export function mountPreview(container,
|
|
222
|
+
export function mountPreview(container, options = {}) {
|
|
69
223
|
if (!container) throw new TypeError("mountPreview: a container element is required");
|
|
224
|
+
for (const key of Object.keys(options ?? {})) {
|
|
225
|
+
if (!KNOWN_OPTIONS.has(key)) throw new TypeError(`mountPreview: unknown option "${key}"`);
|
|
226
|
+
}
|
|
227
|
+
const { glb, elements = {}, viewerState = null, animate = true } = options ?? {};
|
|
228
|
+
const opts = presentation(options ?? {});
|
|
229
|
+
// A carried state as this mount applies it: under camera: "default" without
|
|
230
|
+
// its camera and projection (render mode and environment still carry).
|
|
231
|
+
const carry = (state) => {
|
|
232
|
+
if (!state || !opts.defaultCamera) return state ?? null;
|
|
233
|
+
const { camera: _camera, projection: _projection, ...rest } = state;
|
|
234
|
+
return rest;
|
|
235
|
+
};
|
|
70
236
|
let disposed = false;
|
|
71
237
|
let active = true;
|
|
72
|
-
|
|
238
|
+
// Applied once the viewer exists, and on every setViewerState. The opening
|
|
239
|
+
// render mode, when one is named, wins over the carried one at mount only.
|
|
240
|
+
let carried = carry(viewerState);
|
|
241
|
+
if (opts.renderMode) carried = { ...(carried ?? {}), renderMode: opts.renderMode };
|
|
73
242
|
let live = null; // { viewer, view } once the GLB has decoded
|
|
74
243
|
let player = null; // the baked animation's player, once drawn
|
|
244
|
+
let motion = null; // handle.motion, once the GLB has decoded
|
|
245
|
+
const motionListeners = new Set();
|
|
75
246
|
const cleanups = [];
|
|
76
247
|
const modeListeners = new Set();
|
|
77
248
|
const envListeners = new Set();
|
|
78
249
|
const lostListeners = new Set();
|
|
250
|
+
let resolveDrawn, rejectDrawn;
|
|
251
|
+
const drawn = new Promise((resolve, reject) => { resolveDrawn = resolve; rejectDrawn = reject; });
|
|
252
|
+
drawn.catch(() => {}); // the host awaits it; never an unhandled rejection
|
|
79
253
|
|
|
80
254
|
// Same precedence mount() uses: projection first (orthographic only for a
|
|
81
255
|
// face-view camera), then the camera, then environment and render mode.
|
|
82
256
|
function applyState(s) {
|
|
83
257
|
if (!s || !live) return;
|
|
84
258
|
const { viewer } = live;
|
|
85
|
-
|
|
259
|
+
// Under camera: "default" the framing is the file's own: no projection either.
|
|
260
|
+
if (!opts.defaultCamera) viewer.setProjection(s.projection === "orthographic" && isFaceAlignedState(s.camera) ? "orthographic" : "perspective");
|
|
86
261
|
if (s.camera) viewer.setCameraState(s.camera);
|
|
87
262
|
if (typeof s.environment === "string") viewer.setEnvironment(s.environment);
|
|
88
263
|
if ((s.renderMode === "cad" || s.renderMode === "realistic") && s.renderMode !== viewer.getRenderMode()) {
|
|
@@ -94,39 +269,104 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
94
269
|
if (disposed) return;
|
|
95
270
|
if (decoded.meshes.length === 0) throw new Error("mountPreview: the preview holds no meshes");
|
|
96
271
|
const view = decoded.view ?? "preview";
|
|
97
|
-
const
|
|
272
|
+
const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
273
|
+
const plays = !!decoded.animation && animate !== false;
|
|
274
|
+
// Framing follows the configuration, not reduced motion: a turntable mount
|
|
275
|
+
// is framed swept whether or not it is allowed to turn, so every visitor
|
|
276
|
+
// sees the model at the same size and centre.
|
|
277
|
+
const sweeps = !plays && opts.turntable > 0;
|
|
278
|
+
const turns = sweeps && !reducedMotion;
|
|
279
|
+
const viewer = createViewer(container, previewPart({ ...decoded, view }), {
|
|
280
|
+
transparent: opts.transparent, contactShadow: opts.contactShadow, zoom: opts.zoom, pan: opts.pan,
|
|
281
|
+
// A turning model is framed swept a full turn, so no angle of it clips.
|
|
282
|
+
frameFill: opts.fill, frameSwept: sweeps,
|
|
283
|
+
});
|
|
98
284
|
cleanups.push(() => viewer.dispose());
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
285
|
+
let tooltip = null, toolbar = null, viewStyle = null;
|
|
286
|
+
if (opts.chrome) {
|
|
287
|
+
tooltip = createTooltipPresenter({ id: null });
|
|
288
|
+
const tip = tooltip;
|
|
289
|
+
cleanups.push(() => tip.dispose());
|
|
290
|
+
const viewcube = attachViewcubeControls(viewer, { stage: container });
|
|
291
|
+
cleanups.push(() => viewcube.detach());
|
|
292
|
+
toolbar = container.querySelector("#viewbar");
|
|
293
|
+
const style = viewStyle = attachViewStyleControls(viewer, { stage: container, toolbar, anchor: viewcube.element }, { tooltip });
|
|
294
|
+
cleanups.push(() => style.detach());
|
|
295
|
+
cleanups.push(observeViewbarClear(container, toolbar));
|
|
296
|
+
const chrome = attachViewerControls(viewer, elements.chrome ?? {}, { tooltip });
|
|
297
|
+
cleanups.push(() => chrome.detach());
|
|
298
|
+
}
|
|
109
299
|
cleanups.push(viewer.onRenderModeChange(relay(modeListeners)));
|
|
110
300
|
cleanups.push(viewer.onEnvironmentChange(relay(envListeners)));
|
|
111
301
|
cleanups.push(viewer.onContextLost(relay(lostListeners)));
|
|
112
302
|
for (const m of decoded.meshes) viewer.setSubGeometry(m.name, m);
|
|
113
303
|
viewer.showAssembly(decoded.meshes.map((m) => m.name), { frame: true });
|
|
304
|
+
// The opening framing restart() returns to: the file's default view
|
|
305
|
+
// direction at the fill distance, before any carried camera.
|
|
306
|
+
const opening = viewer.getCameraState();
|
|
114
307
|
live = { viewer, view };
|
|
115
308
|
applyState(carried);
|
|
116
309
|
if (!active) viewer.setActive(false);
|
|
117
|
-
|
|
118
|
-
|
|
310
|
+
const offRendered = viewer.onRendered(() => { offRendered(); resolveDrawn(); });
|
|
311
|
+
cleanups.push(offRendered);
|
|
312
|
+
let turntable = null;
|
|
313
|
+
let lastMotionState = null;
|
|
314
|
+
const motionState = () => {
|
|
315
|
+
if (player && !reducedMotion) {
|
|
316
|
+
if (!player.started()) return "waiting";
|
|
317
|
+
const { status } = player.state();
|
|
318
|
+
return status === "done" ? "finished" : status === "playing" || status === "intro" ? "playing" : "stopped";
|
|
319
|
+
}
|
|
320
|
+
return turntable ? turntable.phase() : "stopped";
|
|
321
|
+
};
|
|
322
|
+
const motionChanged = () => {
|
|
323
|
+
if (disposed) return;
|
|
324
|
+
const now = motionState();
|
|
325
|
+
if (now === lastMotionState) return;
|
|
326
|
+
lastMotionState = now;
|
|
327
|
+
relay(motionListeners)(now);
|
|
328
|
+
};
|
|
329
|
+
if (plays) {
|
|
119
330
|
player = createPreviewPlayer(viewer, decoded.animation, { reducedMotion });
|
|
120
331
|
const own = player;
|
|
121
332
|
cleanups.push(() => own.dispose());
|
|
122
|
-
|
|
333
|
+
cleanups.push(own.onChange(motionChanged));
|
|
334
|
+
if (opts.autostart) player.start();
|
|
335
|
+
else player.prime(); // drawn at the first frame, waiting for motion.start()
|
|
123
336
|
if (!reducedMotion && toolbar) {
|
|
124
337
|
const playButton = attachPreviewPlayButton(own, { toolbar, before: viewStyle.element }, { tooltip });
|
|
125
338
|
cleanups.push(() => playButton.detach());
|
|
126
339
|
}
|
|
340
|
+
} else if (turns) {
|
|
341
|
+
const own = turntable = createTurntable(viewer, container, opts.turntable, motionChanged);
|
|
342
|
+
cleanups.push(() => own.dispose());
|
|
343
|
+
if (opts.autostart) turntable.start();
|
|
127
344
|
}
|
|
345
|
+
lastMotionState = motionState();
|
|
346
|
+
motion = {
|
|
347
|
+
get state() { return motionState(); },
|
|
348
|
+
start() {
|
|
349
|
+
if (disposed) return;
|
|
350
|
+
if (player) player.start(); // a no-op once started or under reduced motion
|
|
351
|
+
else turntable?.start();
|
|
352
|
+
motionChanged();
|
|
353
|
+
},
|
|
354
|
+
restart() {
|
|
355
|
+
if (disposed) return;
|
|
356
|
+
viewer.cancelCameraTween();
|
|
357
|
+
viewer.setCameraState(opening);
|
|
358
|
+
if (player) player.restart(); // nothing under reduced motion: the pose is already t = 0
|
|
359
|
+
else turntable?.restart();
|
|
360
|
+
motionChanged();
|
|
361
|
+
},
|
|
362
|
+
onChange(cb) {
|
|
363
|
+
if (disposed) return () => {};
|
|
364
|
+
motionListeners.add(cb);
|
|
365
|
+
return () => motionListeners.delete(cb);
|
|
366
|
+
},
|
|
367
|
+
};
|
|
128
368
|
});
|
|
129
|
-
ready.catch(
|
|
369
|
+
ready.catch(rejectDrawn); // the host awaits it; never an unhandled rejection
|
|
130
370
|
|
|
131
371
|
function getViewerState() {
|
|
132
372
|
if (!live) return { camera: null, projection: "perspective", renderMode: "cad", ...carried, cutaway: null };
|
|
@@ -148,11 +388,15 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
148
388
|
|
|
149
389
|
return {
|
|
150
390
|
ready,
|
|
391
|
+
drawn,
|
|
151
392
|
dispose() {
|
|
152
393
|
if (disposed) return;
|
|
153
394
|
disposed = true;
|
|
395
|
+
rejectDrawn(new Error("mountPreview: disposed before the preview was drawn")); // a no-op once drawn
|
|
154
396
|
live = null;
|
|
155
397
|
player = null; // `animation` answers null once disposed (the cleanup below disposes it)
|
|
398
|
+
motion = null;
|
|
399
|
+
motionListeners.clear();
|
|
156
400
|
const errors = [];
|
|
157
401
|
while (cleanups.length) {
|
|
158
402
|
try { cleanups.pop()(); } catch (error) { errors.push(error); }
|
|
@@ -165,9 +409,11 @@ export function mountPreview(container, { glb, elements = {}, viewerState = null
|
|
|
165
409
|
get animation() {
|
|
166
410
|
return player ? { state: player.state, pause: player.pause, resume: player.resume, replay: player.replay } : null;
|
|
167
411
|
},
|
|
412
|
+
// null before ready and once disposed; present for every decoded file.
|
|
413
|
+
get motion() { return motion; },
|
|
168
414
|
getViewerState,
|
|
169
415
|
setViewerState(next) {
|
|
170
|
-
carried = next
|
|
416
|
+
carried = carry(next);
|
|
171
417
|
applyState(carried);
|
|
172
418
|
},
|
|
173
419
|
setActive(next) {
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Three states, read off the player's onChange notifications (never polled):
|
|
7
7
|
// - Pause while playing or during the intro camera tween;
|
|
8
|
-
// - Play while paused — resumes from the pose it froze at
|
|
8
|
+
// - Play while paused — resumes from the pose it froze at — or while a
|
|
9
|
+
// mount with `autostart: false` waits at its first frame (starts it);
|
|
9
10
|
// - Replay once a one-time animation has finished at its end pose — plays it
|
|
10
11
|
// again from the start, camera cues included. A looping animation
|
|
11
12
|
// never gets here.
|
|
@@ -30,7 +31,7 @@ const STATES = {
|
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
// What a click does for a given player status.
|
|
33
|
-
const actionFor = (status) => (status === "paused" ? "play" : status === "done" ? "replay" : "pause");
|
|
34
|
+
const actionFor = (status) => (status === "paused" || status === "idle" ? "play" : status === "done" ? "replay" : "pause");
|
|
34
35
|
|
|
35
36
|
export function attachPreviewPlayButton(player, { toolbar, before = null }, { tooltip = null } = {}) {
|
|
36
37
|
const button = document.createElement("button");
|
|
@@ -58,7 +59,7 @@ export function attachPreviewPlayButton(player, { toolbar, before = null }, { to
|
|
|
58
59
|
|
|
59
60
|
const onClick = () => {
|
|
60
61
|
if (action === "pause") player.pause();
|
|
61
|
-
else if (action === "play") player.resume();
|
|
62
|
+
else if (action === "play") { if (player.state().status === "idle") player.start(); else player.resume(); }
|
|
62
63
|
else player.replay();
|
|
63
64
|
};
|
|
64
65
|
button.addEventListener("click", onClick);
|
|
@@ -26,6 +26,12 @@
|
|
|
26
26
|
// re-honors an abandoned intro or sweep on the next play(). An orbit while
|
|
27
27
|
// paused stays paused. onChange(cb) reports every status change, so a control
|
|
28
28
|
// never polls.
|
|
29
|
+
//
|
|
30
|
+
// A host that wants the model to WAIT at its first frame (mountPreview's
|
|
31
|
+
// `autostart: false`, 0.169.0) calls prime() instead of start(): the t = 0 pose
|
|
32
|
+
// and opacity are drawn — exactly what start() draws first — and nothing
|
|
33
|
+
// advances until start(). restart() plays again from t = 0 from any state
|
|
34
|
+
// (waiting, playing, paused, done), re-arming the cues and the intro.
|
|
29
35
|
import { createPlayback } from "./animation.js";
|
|
30
36
|
import { invertRigid, mulMat4 } from "./geometry/pose.js";
|
|
31
37
|
|
|
@@ -106,7 +112,7 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
106
112
|
// already sits at its t = 0 placement, so the identity pose is the rest pose.
|
|
107
113
|
const restInverse = Object.fromEntries(Object.keys(tracks).map((name) => [name, invertRigid(rest[name])]));
|
|
108
114
|
|
|
109
|
-
let offFrame = null, offOrbit = null, started = false, disposed = false;
|
|
115
|
+
let offFrame = null, offOrbit = null, started = false, disposed = false, held = false;
|
|
110
116
|
const listeners = new Set();
|
|
111
117
|
let lastStatus = playback.state().status;
|
|
112
118
|
|
|
@@ -151,13 +157,24 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
151
157
|
if (playback.state().status === "intro") { playback.introDone(); notify(); }
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
|
|
160
|
+
// The t = 0 pose and opacity, with the ground held for what moves next.
|
|
161
|
+
function firstFrame() {
|
|
162
|
+
apply(0);
|
|
163
|
+
if (!held) { viewer.holdGround(true); held = true; }
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const player = {
|
|
167
|
+
// Draw the first frame and wait there (no-op under reduced motion, once
|
|
168
|
+
// started, or once disposed).
|
|
169
|
+
prime() {
|
|
170
|
+
if (started || disposed || reducedMotion) return;
|
|
171
|
+
firstFrame();
|
|
172
|
+
},
|
|
155
173
|
start() {
|
|
156
174
|
if (started || disposed) return;
|
|
157
175
|
started = true;
|
|
158
176
|
if (reducedMotion) return;
|
|
159
|
-
|
|
160
|
-
viewer.holdGround(true);
|
|
177
|
+
firstFrame();
|
|
161
178
|
offFrame = viewer.onFrame(onFrame);
|
|
162
179
|
offOrbit = viewer.onCameraStart(onOrbit);
|
|
163
180
|
honorIntro(playback.play());
|
|
@@ -185,6 +202,20 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
185
202
|
honorIntro(playback.play());
|
|
186
203
|
notify();
|
|
187
204
|
},
|
|
205
|
+
// From any state: back to t = 0 (pose, opacity, cues re-armed) and play,
|
|
206
|
+
// its intro cue included. Starts a player that was waiting. Nothing under
|
|
207
|
+
// reduced motion — the file's own opening pose is already the t = 0 pose.
|
|
208
|
+
restart() {
|
|
209
|
+
if (disposed || reducedMotion) return;
|
|
210
|
+
if (!started) { player.start(); return; }
|
|
211
|
+
viewer.cancelCameraTween();
|
|
212
|
+
playback.reset();
|
|
213
|
+
apply(0);
|
|
214
|
+
honorIntro(playback.play());
|
|
215
|
+
notify();
|
|
216
|
+
},
|
|
217
|
+
// True once start() has run (under reduced motion too).
|
|
218
|
+
started: () => started,
|
|
188
219
|
onChange(cb) {
|
|
189
220
|
if (disposed) return () => {};
|
|
190
221
|
listeners.add(cb);
|
|
@@ -194,11 +225,10 @@ export function createPreviewPlayer(viewer, animation, { reducedMotion = false }
|
|
|
194
225
|
if (disposed) return;
|
|
195
226
|
disposed = true;
|
|
196
227
|
listeners.clear();
|
|
197
|
-
if (
|
|
198
|
-
|
|
199
|
-
offFrame = offOrbit = null;
|
|
200
|
-
viewer.holdGround(false);
|
|
228
|
+
if (offFrame) { offFrame(); offOrbit(); offFrame = offOrbit = null; }
|
|
229
|
+
if (held) { held = false; viewer.holdGround(false); } // primed or played
|
|
201
230
|
},
|
|
202
231
|
state: snapshotState,
|
|
203
232
|
};
|
|
233
|
+
return player;
|
|
204
234
|
}
|
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).
|
|
@@ -1849,6 +1928,7 @@ export function createViewer(container, part) {
|
|
|
1849
1928
|
|
|
1850
1929
|
// --- show / hide assembly -------------------------------------------------
|
|
1851
1930
|
const _box = new THREE.Box3();
|
|
1931
|
+
const _origin = new THREE.Vector3();
|
|
1852
1932
|
const _posedBox = new THREE.Box3();
|
|
1853
1933
|
const _frameDir = new THREE.Vector3();
|
|
1854
1934
|
|
|
@@ -1864,6 +1944,11 @@ export function createViewer(container, part) {
|
|
|
1864
1944
|
}
|
|
1865
1945
|
if (_box.isEmpty()) return;
|
|
1866
1946
|
const center = _box.getCenter(new THREE.Vector3());
|
|
1947
|
+
// A fill framing centres on the geometry, not its box: the horizontal
|
|
1948
|
+
// vertex centroid (model x/y — the pivot stands model Z up) with the box's
|
|
1949
|
+
// vertical centre. A lopsided part (a three-blade propeller) then turns
|
|
1950
|
+
// about its hub instead of orbiting the middle of its box.
|
|
1951
|
+
if (frameFill) horizontalCentroid(visibleNames, center);
|
|
1867
1952
|
partsGroup.position.copy(center).multiplyScalar(-1); // centre assembly on the pivot
|
|
1868
1953
|
const size = _box.getSize(new THREE.Vector3());
|
|
1869
1954
|
// Drop the grid to the bottom of the bounding box (model Z -> world Y), so it reads
|
|
@@ -1871,6 +1956,7 @@ export function createViewer(container, part) {
|
|
|
1871
1956
|
floorY = -size.z / 2;
|
|
1872
1957
|
grid.position.y = floorY;
|
|
1873
1958
|
const r = Math.max(size.x, size.y, size.z) || 12;
|
|
1959
|
+
const fill = fillFraming(_box, visibleNames, center);
|
|
1874
1960
|
// Keep the VIEW DIRECTION (target -> camera), not the camera's direction
|
|
1875
1961
|
// from the origin: after a pan the target is off the origin, and scaling
|
|
1876
1962
|
// the position about the origin would turn the view — which, in an ortho
|
|
@@ -1880,11 +1966,239 @@ export function createViewer(container, part) {
|
|
|
1880
1966
|
_frameDir.copy(activeCamera.position).sub(controls.target);
|
|
1881
1967
|
if (_frameDir.lengthSq() === 0) _frameDir.copy(activeCamera.position);
|
|
1882
1968
|
if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
|
|
1883
|
-
|
|
1884
|
-
|
|
1969
|
+
_frameDir.normalize();
|
|
1970
|
+
const filled = fill?.(_frameDir);
|
|
1971
|
+
// A silhouette-centred fill framing aims off the pivot: the target moves in
|
|
1972
|
+
// the screen plane (and the camera with it, keeping the view direction).
|
|
1973
|
+
controls.target.copy(filled?.offset ?? _origin);
|
|
1974
|
+
activeCamera.position.copy(_frameDir).multiplyScalar(filled ? filled.distance : r * 2.6 + 6).add(controls.target);
|
|
1885
1975
|
// Framing under ortho is a frustum, not a distance — without this the
|
|
1886
|
-
// reframe button moves the camera and nothing visibly changes.
|
|
1887
|
-
|
|
1976
|
+
// reframe button moves the camera and nothing visibly changes. A fill
|
|
1977
|
+
// framing sizes the frustum to the projected box directly (the sync
|
|
1978
|
+
// matches size at the surface under the screen centre, not the extent).
|
|
1979
|
+
if (projectionMode === "orthographic") {
|
|
1980
|
+
if (filled) {
|
|
1981
|
+
applyOrthoFrustum({ halfH: filled.orthoHalfH, halfW: filled.orthoHalfH * filled.aspect });
|
|
1982
|
+
orthoCamera.zoom = 1;
|
|
1983
|
+
orthoCamera.updateProjectionMatrix();
|
|
1984
|
+
} else {
|
|
1985
|
+
syncOrthoToPerspectiveFraming();
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// The `frameFill` framing. `size` is the posed box in the model frame; the
|
|
1991
|
+
// assembly is centred on the pivot, so its world corners are the half-extents
|
|
1992
|
+
// stood up by the pivot's rotation. With `frameSwept` the points are the
|
|
1993
|
+
// model swept about the vertical through the box centre — two circles at the
|
|
1994
|
+
// box's top and bottom whose radius is the farthest posed VERTEX from that
|
|
1995
|
+
// axis (nudged out so the polygon contains the circle). Not the box's
|
|
1996
|
+
// corners: those sit ~√2 farther out than a round part's rim, which framed a
|
|
1997
|
+
// turning gear at little more than half the asked-for width. Returns a function of the unit view
|
|
1998
|
+
// direction (target → camera) giving `{ distance, orthoHalfH, aspect }`:
|
|
1999
|
+
// - perspective: the distance, along that direction to the origin target,
|
|
2000
|
+
// at which the points' projected extent — the larger of (projected width /
|
|
2001
|
+
// viewport width, projected height / viewport height) — equals frameFill.
|
|
2002
|
+
// The extent only shrinks as the camera backs off, so it is bisected.
|
|
2003
|
+
// - orthographic: the half-height that gives the same extent.
|
|
2004
|
+
// Null when unset, or when the container has no size yet (a hidden or
|
|
2005
|
+
// unlaid-out stage): frameTo then falls back to the fixed distance, and the
|
|
2006
|
+
// next reframe fills.
|
|
2007
|
+
//
|
|
2008
|
+
// Without `frameSwept` (0.169.0) the points are the posed mesh VERTICES, not
|
|
2009
|
+
// the box corners, and the framing is silhouette-centred: the result also
|
|
2010
|
+
// carries `offset`, a world-space shift of the target (and the camera with
|
|
2011
|
+
// it) in the screen plane that centres the vertices' projected bounding box,
|
|
2012
|
+
// and the distance makes that box span frameFill along its limiting axis.
|
|
2013
|
+
// Box-centred framing put an obliquely-viewed wide flat part low on screen —
|
|
2014
|
+
// its near-bottom corner projects far past the far-top one — and fitted
|
|
2015
|
+
// corners that no vertex reaches. The swept framing keeps the target on the
|
|
2016
|
+
// pivot, so a turntable still turns about the hub.
|
|
2017
|
+
const SWEEP_SEGMENTS = 48;
|
|
2018
|
+
const _sweepV = new THREE.Vector3();
|
|
2019
|
+
// The farthest horizontal distance of any posed vertex of `names` from the
|
|
2020
|
+
// vertical through `center` (model frame), in world units. Reads positions
|
|
2021
|
+
// through BufferAttribute accessors, so normalized/quantized data is
|
|
2022
|
+
// dequantized.
|
|
2023
|
+
function sweepRadius(names, center) {
|
|
2024
|
+
let max = 0;
|
|
2025
|
+
for (const name of names) {
|
|
2026
|
+
const pos = subCache[name]?.getAttribute("position");
|
|
2027
|
+
if (!pos) continue;
|
|
2028
|
+
const pose = subMesh[name].matrix;
|
|
2029
|
+
for (let i = 0; i < pos.count; i++) {
|
|
2030
|
+
_sweepV.fromBufferAttribute(pos, i).applyMatrix4(pose).sub(center).applyQuaternion(pivot.quaternion);
|
|
2031
|
+
const d = Math.hypot(_sweepV.x, _sweepV.z);
|
|
2032
|
+
if (d > max) max = d;
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
return max;
|
|
2036
|
+
}
|
|
2037
|
+
// Overwrite `center`'s model x/y with the mean posed vertex position of
|
|
2038
|
+
// `names` (z untouched). A plain vertex mean: preview meshes are close enough
|
|
2039
|
+
// to uniformly tessellated for it to land on the hub of a lopsided part.
|
|
2040
|
+
function horizontalCentroid(names, center) {
|
|
2041
|
+
let sx = 0, sy = 0, n = 0;
|
|
2042
|
+
for (const name of names) {
|
|
2043
|
+
const pos = subCache[name]?.getAttribute("position");
|
|
2044
|
+
if (!pos) continue;
|
|
2045
|
+
const pose = subMesh[name].matrix;
|
|
2046
|
+
for (let i = 0; i < pos.count; i++) {
|
|
2047
|
+
_sweepV.fromBufferAttribute(pos, i).applyMatrix4(pose);
|
|
2048
|
+
sx += _sweepV.x; sy += _sweepV.y; n++;
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
if (n > 0) center.set(sx / n, sy / n, center.z);
|
|
2052
|
+
}
|
|
2053
|
+
// Every posed vertex of `names` about `center`, stood up by the pivot, as
|
|
2054
|
+
// flat x/y/z arrays (world units, pivot-relative).
|
|
2055
|
+
function posedVertices(names, center) {
|
|
2056
|
+
let n = 0;
|
|
2057
|
+
for (const name of names) n += subCache[name]?.getAttribute("position")?.count ?? 0;
|
|
2058
|
+
const px = new Float64Array(n), py = new Float64Array(n), pz = new Float64Array(n);
|
|
2059
|
+
let k = 0;
|
|
2060
|
+
for (const name of names) {
|
|
2061
|
+
const pos = subCache[name]?.getAttribute("position");
|
|
2062
|
+
if (!pos) continue;
|
|
2063
|
+
const pose = subMesh[name].matrix;
|
|
2064
|
+
for (let i = 0; i < pos.count; i++, k++) {
|
|
2065
|
+
_sweepV.fromBufferAttribute(pos, i).applyMatrix4(pose).sub(center).applyQuaternion(pivot.quaternion);
|
|
2066
|
+
px[k] = _sweepV.x; py[k] = _sweepV.y; pz[k] = _sweepV.z;
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
return { px, py, pz, n };
|
|
2070
|
+
}
|
|
2071
|
+
|
|
2072
|
+
// The silhouette-centred fill framing of posed vertices (see above).
|
|
2073
|
+
function centredFraming({ px, py, pz, n }, aspect) {
|
|
2074
|
+
if (n === 0) return null;
|
|
2075
|
+
let any = false;
|
|
2076
|
+
for (let i = 0; i < n && !any; i++) any = px[i] !== 0 || py[i] !== 0 || pz[i] !== 0;
|
|
2077
|
+
if (!any) return null;
|
|
2078
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
|
|
2079
|
+
const tanH = tanV * aspect;
|
|
2080
|
+
const xs = new Float64Array(n), ys = new Float64Array(n), along = new Float64Array(n);
|
|
2081
|
+
return (dir) => {
|
|
2082
|
+
const forward = dir.clone().negate();
|
|
2083
|
+
const right = new THREE.Vector3().crossVectors(forward, activeCamera.up);
|
|
2084
|
+
if (right.lengthSq() < 1e-12) right.set(1, 0, 0);
|
|
2085
|
+
right.normalize();
|
|
2086
|
+
const upAxis = new THREE.Vector3().crossVectors(right, forward);
|
|
2087
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, nearest = -Infinity;
|
|
2088
|
+
for (let i = 0; i < n; i++) {
|
|
2089
|
+
xs[i] = px[i] * right.x + py[i] * right.y + pz[i] * right.z;
|
|
2090
|
+
ys[i] = px[i] * upAxis.x + py[i] * upAxis.y + pz[i] * upAxis.z;
|
|
2091
|
+
along[i] = px[i] * dir.x + py[i] * dir.y + pz[i] * dir.z;
|
|
2092
|
+
if (xs[i] < x0) x0 = xs[i]; if (xs[i] > x1) x1 = xs[i];
|
|
2093
|
+
if (ys[i] < y0) y0 = ys[i]; if (ys[i] > y1) y1 = ys[i];
|
|
2094
|
+
if (along[i] > nearest) nearest = along[i];
|
|
2095
|
+
}
|
|
2096
|
+
// Orthographic: centre the extent exactly; the half-height that fits it.
|
|
2097
|
+
const orthoHalfH = Math.max((x1 - x0) / (2 * frameFill * aspect), (y1 - y0) / (2 * frameFill)) || 1;
|
|
2098
|
+
const orthoOffset = right.clone().multiplyScalar((x0 + x1) / 2).addScaledVector(upAxis, (y0 + y1) / 2);
|
|
2099
|
+
if (projectionMode === "orthographic") {
|
|
2100
|
+
const d = Math.max(nearest, 0) + Math.max(x1 - x0, y1 - y0) * 2 + 6;
|
|
2101
|
+
return { distance: d, orthoHalfH, aspect, offset: orthoOffset };
|
|
2102
|
+
}
|
|
2103
|
+
// Perspective: the projected box, the target shifted (tx, ty) in the
|
|
2104
|
+
// screen plane, the camera at distance d along `dir`.
|
|
2105
|
+
let tx = (x0 + x1) / 2, ty = (y0 + y1) / 2;
|
|
2106
|
+
const ext = (d) => {
|
|
2107
|
+
let a0 = Infinity, a1 = -Infinity, b0 = Infinity, b1 = -Infinity;
|
|
2108
|
+
for (let i = 0; i < n; i++) {
|
|
2109
|
+
const z = d - along[i];
|
|
2110
|
+
const nx = (xs[i] - tx) / (z * tanH), ny = (ys[i] - ty) / (z * tanV);
|
|
2111
|
+
if (nx < a0) a0 = nx; if (nx > a1) a1 = nx;
|
|
2112
|
+
if (ny < b0) b0 = ny; if (ny > b1) b1 = ny;
|
|
2113
|
+
}
|
|
2114
|
+
return [a0, a1, b0, b1];
|
|
2115
|
+
};
|
|
2116
|
+
const over = (d) => { const [a0, a1, b0, b1] = ext(d); return Math.max(a1 - a0, b1 - b0) / (2 * frameFill); };
|
|
2117
|
+
const fit = () => {
|
|
2118
|
+
let lo = nearest + 1e-6 * (1 + Math.abs(nearest)), hi = Math.max(lo * 2, 1);
|
|
2119
|
+
while (over(hi) > 1 && hi < 1e9) hi *= 2;
|
|
2120
|
+
for (let i = 0; i < 48; i++) {
|
|
2121
|
+
const mid = (lo + hi) / 2;
|
|
2122
|
+
if (over(mid) > 1) lo = mid; else hi = mid;
|
|
2123
|
+
}
|
|
2124
|
+
return hi;
|
|
2125
|
+
};
|
|
2126
|
+
let d = fit();
|
|
2127
|
+
// Recentre and refit: the shift moves each vertex's NDC by 1/depth, so a
|
|
2128
|
+
// few rounds converge.
|
|
2129
|
+
for (let round = 0; round < 8; round++) {
|
|
2130
|
+
const [a0, a1, b0, b1] = ext(d);
|
|
2131
|
+
const cx = (a0 + a1) / 2, cy = (b0 + b1) / 2;
|
|
2132
|
+
if (Math.abs(cx) < 1e-5 && Math.abs(cy) < 1e-5) break;
|
|
2133
|
+
tx += cx * d * tanH;
|
|
2134
|
+
ty += cy * d * tanV;
|
|
2135
|
+
d = fit();
|
|
2136
|
+
}
|
|
2137
|
+
return { distance: d, orthoHalfH, aspect, offset: right.multiplyScalar(tx).addScaledVector(upAxis, ty) };
|
|
2138
|
+
};
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
function fillFraming(box, names, center) {
|
|
2142
|
+
if (!frameFill) return null;
|
|
2143
|
+
const w = container.clientWidth, h = container.clientHeight;
|
|
2144
|
+
if (!(w > 0 && h > 0)) return null;
|
|
2145
|
+
const aspect = w / h;
|
|
2146
|
+
pivot.updateMatrix();
|
|
2147
|
+
if (!frameSwept) return centredFraming(posedVertices(names, center), aspect);
|
|
2148
|
+
// The box corners about the framing centre (no longer the box's own centre).
|
|
2149
|
+
let points = [];
|
|
2150
|
+
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]) {
|
|
2151
|
+
points.push(new THREE.Vector3(x, y, z).sub(center).applyQuaternion(pivot.quaternion));
|
|
2152
|
+
}
|
|
2153
|
+
if (frameSwept) {
|
|
2154
|
+
const radius = sweepRadius(names, center) / Math.cos(Math.PI / SWEEP_SEGMENTS);
|
|
2155
|
+
const ys = [Math.min(...points.map((p) => p.y)), Math.max(...points.map((p) => p.y))];
|
|
2156
|
+
points = [];
|
|
2157
|
+
for (let k = 0; k < SWEEP_SEGMENTS; k++) {
|
|
2158
|
+
const a = (2 * Math.PI * k) / SWEEP_SEGMENTS;
|
|
2159
|
+
for (const y of ys) points.push(new THREE.Vector3(radius * Math.cos(a), y, radius * Math.sin(a)));
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
if (points.every((p) => p.lengthSq() === 0)) return null;
|
|
2163
|
+
const tanV = Math.tan(THREE.MathUtils.degToRad(camera.fov) / 2);
|
|
2164
|
+
const tanH = tanV * aspect;
|
|
2165
|
+
return (dir) => {
|
|
2166
|
+
// The camera's basis for this view direction, `up` as the controls keep it.
|
|
2167
|
+
const forward = dir.clone().negate();
|
|
2168
|
+
const right = new THREE.Vector3().crossVectors(forward, activeCamera.up);
|
|
2169
|
+
if (right.lengthSq() < 1e-12) right.set(1, 0, 0); // looking straight along up
|
|
2170
|
+
right.normalize();
|
|
2171
|
+
const upAxis = new THREE.Vector3().crossVectors(right, forward);
|
|
2172
|
+
const xs = points.map((p) => p.dot(right)), ys = points.map((p) => p.dot(upAxis)), along = points.map((p) => p.dot(dir));
|
|
2173
|
+
// `fill` is the projected SPAN: the larger of (projected width / viewport
|
|
2174
|
+
// width, projected height / viewport height). The framing centre sits at
|
|
2175
|
+
// the screen centre, though, and a lopsided part reaches farther one way
|
|
2176
|
+
// than the other — so the camera also backs off until nothing leaves the
|
|
2177
|
+
// viewport (|NDC| <= 1 on both axes). For a part about as symmetric as
|
|
2178
|
+
// its centre, the span decides alone.
|
|
2179
|
+
const span = (v) => Math.max(...v) - Math.min(...v);
|
|
2180
|
+
const reach = (v) => Math.max(...v.map(Math.abs));
|
|
2181
|
+
const orthoHalfH = Math.max(span(xs) / (2 * frameFill * aspect), span(ys) / (2 * frameFill), reach(xs) / aspect, reach(ys));
|
|
2182
|
+
// How far over budget the projection is at distance d (<= 1 fits).
|
|
2183
|
+
const over = (d) => {
|
|
2184
|
+
let x0 = Infinity, x1 = -Infinity, y0 = Infinity, y1 = -Infinity, r = 0;
|
|
2185
|
+
for (let i = 0; i < points.length; i++) {
|
|
2186
|
+
const z = d - along[i]; // depth in front of the camera
|
|
2187
|
+
const nx = xs[i] / (z * tanH), ny = ys[i] / (z * tanV);
|
|
2188
|
+
x0 = Math.min(x0, nx); x1 = Math.max(x1, nx); y0 = Math.min(y0, ny); y1 = Math.max(y1, ny);
|
|
2189
|
+
r = Math.max(r, Math.abs(nx), Math.abs(ny));
|
|
2190
|
+
}
|
|
2191
|
+
return Math.max(Math.max(x1 - x0, y1 - y0) / (2 * frameFill), r); // NDC spans 2 per axis
|
|
2192
|
+
};
|
|
2193
|
+
const nearest = Math.max(...along);
|
|
2194
|
+
let lo = nearest + 1e-6 * (1 + Math.abs(nearest)), hi = Math.max(lo * 2, 1);
|
|
2195
|
+
while (over(hi) > 1 && hi < 1e9) hi *= 2;
|
|
2196
|
+
for (let i = 0; i < 60; i++) {
|
|
2197
|
+
const mid = (lo + hi) / 2;
|
|
2198
|
+
if (over(mid) > 1) lo = mid; else hi = mid;
|
|
2199
|
+
}
|
|
2200
|
+
return { distance: hi, orthoHalfH, aspect };
|
|
2201
|
+
};
|
|
1888
2202
|
}
|
|
1889
2203
|
|
|
1890
2204
|
// Show exactly the named sub-parts (from the cache). When `frame` is set, also
|
|
@@ -1906,6 +2220,7 @@ export function createViewer(container, part) {
|
|
|
1906
2220
|
if (frame) frameTo(visibleNames);
|
|
1907
2221
|
cutaway.setVisible(effectiveVisible());
|
|
1908
2222
|
placeGround(); // realistic only: the ground comes to the part, and the shadow re-renders
|
|
2223
|
+
if (contactShadow) rebuildLiveShadow(); // CAD only (a no-op in realistic)
|
|
1909
2224
|
for (const cb of [...assemblyListeners]) {
|
|
1910
2225
|
try { cb(); } catch (e) { console.warn("partforge: assembly listener failed", e); }
|
|
1911
2226
|
}
|
|
@@ -1949,11 +2264,11 @@ export function createViewer(container, part) {
|
|
|
1949
2264
|
const t = THEME[mode] ?? THEME.dark;
|
|
1950
2265
|
// Realistic mode's backdrop belongs to the environment and its grid is off;
|
|
1951
2266
|
// enterCad puts the theme's background back on the way out.
|
|
1952
|
-
if (renderMode === "cad") scene.background =
|
|
2267
|
+
if (renderMode === "cad") scene.background = cadBackground(THEME[mode] ? mode : "dark");
|
|
1953
2268
|
scene.remove(grid);
|
|
1954
2269
|
grid = new THREE.GridHelper(GRID_SIZE, GRID_DIVS, t.grid[0], t.grid[1]);
|
|
1955
2270
|
grid.position.y = floorY; // keep the floor at the bbox bottom across theme swaps
|
|
1956
|
-
grid.visible = renderMode === "cad";
|
|
2271
|
+
grid.visible = renderMode === "cad" && cadGrid;
|
|
1957
2272
|
scene.add(grid);
|
|
1958
2273
|
lineMaterial.color.set(t.line);
|
|
1959
2274
|
for (const m of fadeLineMats.values()) m.color.set(t.line); // clones follow the theme
|
|
@@ -2756,6 +3071,9 @@ export function createViewer(container, part) {
|
|
|
2756
3071
|
}
|
|
2757
3072
|
renderWithBackdrop(scene, activeCamera);
|
|
2758
3073
|
cutaway.renderOverlay(renderer, activeCamera);
|
|
3074
|
+
for (const cb of [...renderedListeners]) {
|
|
3075
|
+
try { cb(); } catch (e) { console.warn("partforge: rendered listener failed", e); }
|
|
3076
|
+
}
|
|
2759
3077
|
}
|
|
2760
3078
|
renderer.setAnimationLoop(renderFrame);
|
|
2761
3079
|
|
|
@@ -3032,6 +3350,7 @@ export function createViewer(container, part) {
|
|
|
3032
3350
|
controls.removeEventListener("start", onControlsStart);
|
|
3033
3351
|
cameraStartListeners.clear();
|
|
3034
3352
|
frameListeners.clear();
|
|
3353
|
+
renderedListeners.clear();
|
|
3035
3354
|
assemblyListeners.clear();
|
|
3036
3355
|
themeListeners.clear();
|
|
3037
3356
|
projectionListeners.clear();
|
|
@@ -3078,6 +3397,7 @@ export function createViewer(container, part) {
|
|
|
3078
3397
|
fadeMats.clear();
|
|
3079
3398
|
fadeLineMats.clear();
|
|
3080
3399
|
disposeRealistic();
|
|
3400
|
+
disposeLiveShadow();
|
|
3081
3401
|
material.dispose();
|
|
3082
3402
|
lineMaterial.dispose();
|
|
3083
3403
|
grid.geometry.dispose();
|
|
@@ -3113,6 +3433,7 @@ export function createViewer(container, part) {
|
|
|
3113
3433
|
renderMaterialThumbnail,
|
|
3114
3434
|
invalidateMaterialThumbnails,
|
|
3115
3435
|
onFrame,
|
|
3436
|
+
onRendered,
|
|
3116
3437
|
onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
|
|
3117
3438
|
tweenCameraTo,
|
|
3118
3439
|
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,59 @@ 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 until `motion.restart()`.
|
|
825
|
+
*/
|
|
826
|
+
turntable?: number;
|
|
827
|
+
/**
|
|
828
|
+
* `false` (0.169.0+; default true): the motion — a playing animation or the
|
|
829
|
+
* turntable — waits at its first frame (the animation's t = 0 pose, turntable
|
|
830
|
+
* angle 0) until `motion.start()`. `drawn` resolves on a frame showing that pose.
|
|
831
|
+
*/
|
|
832
|
+
autostart?: boolean;
|
|
833
|
+
/**
|
|
834
|
+
* 0 < fill <= 1 (0.168.0+): the opening framing (and the viewer's reframe) puts the
|
|
835
|
+
* camera, along the opening view direction, so the model's bounding box as projected
|
|
836
|
+
* on screen spans this fraction of the viewport along its limiting axis. With a
|
|
837
|
+
* `turntable` (and no baked animation) the model swept a full turn is framed — its
|
|
838
|
+
* farthest vertex from the vertical axis, over the box's height — so the turn never
|
|
839
|
+
* clips and a round part still fills, centred on its horizontal vertex centroid (a
|
|
840
|
+
* lopsided part turns about its hub). Without a turntable (0.169.0+) the posed mesh
|
|
841
|
+
* vertices are fitted and their on-screen outline is centred (the target moves in the
|
|
842
|
+
* screen plane), spanning `fill`; a baked animation that moves parts outside its first
|
|
843
|
+
* pose's outline may leave the frame. Unset, the
|
|
844
|
+
* fixed framing distance. Since 0.169.0 a turntable mount is framed swept under reduced
|
|
845
|
+
* motion too, so every visitor sees the same framing.
|
|
846
|
+
*/
|
|
847
|
+
fill?: number;
|
|
848
|
+
/** `"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"`. */
|
|
849
|
+
camera?: "carry" | "default";
|
|
850
|
+
/** 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. */
|
|
851
|
+
renderMode?: RenderMode;
|
|
811
852
|
}
|
|
812
853
|
|
|
813
854
|
/** The handle `mountPreview()` returns. Every method is safe before `ready` settles. */
|
|
814
855
|
export interface PreviewRuntime {
|
|
815
856
|
/** Resolves once the GLB is drawn; rejects when it will not decode or holds no meshes. */
|
|
816
857
|
ready: Promise<void>;
|
|
858
|
+
/**
|
|
859
|
+
* Resolves after the first frame that includes the meshes has rendered (0.168.0+) —
|
|
860
|
+
* for cross-fading from a still image. Rejects with `ready`'s error when the file
|
|
861
|
+
* will not decode, or when the handle is disposed first. Waits while parked.
|
|
862
|
+
*/
|
|
863
|
+
drawn: Promise<void>;
|
|
817
864
|
/** Full teardown. Idempotent. */
|
|
818
865
|
dispose(): void;
|
|
819
866
|
/** The view the file was written for (`null` until drawn, or when the file names none). */
|
|
@@ -838,6 +885,25 @@ export interface PreviewRuntime {
|
|
|
838
885
|
/** Play a finished one-time animation again from the start (its camera cues included); a no-op unless `state().status` is `"done"`. */
|
|
839
886
|
replay(): void;
|
|
840
887
|
} | null;
|
|
888
|
+
/**
|
|
889
|
+
* The motion — baked animation or turntable — (0.169.0+): `null` before `ready` and
|
|
890
|
+
* once disposed; present for every decoded file.
|
|
891
|
+
*/
|
|
892
|
+
readonly motion: {
|
|
893
|
+
/** Begin a waiting motion. Idempotent; a no-op once started, under reduced motion, or with nothing to move. */
|
|
894
|
+
start(): void;
|
|
895
|
+
/**
|
|
896
|
+
* Put the model back as it opened and start it: the opening camera framing (the
|
|
897
|
+
* file's default view direction, before any carried camera), the animation from
|
|
898
|
+
* t = 0 or the turntable turning again (re-armed). Under reduced motion it only
|
|
899
|
+
* resets the camera.
|
|
900
|
+
*/
|
|
901
|
+
restart(): void;
|
|
902
|
+
/** `"stopped"`: a turntable stopped by a grab, a paused animation, reduced motion, or nothing to move. */
|
|
903
|
+
readonly state: "waiting" | "playing" | "stopped" | "finished";
|
|
904
|
+
/** Each state change. Returns an unsubscribe. */
|
|
905
|
+
onChange(cb: (state: "waiting" | "playing" | "stopped" | "finished") => void): () => void;
|
|
906
|
+
} | null;
|
|
841
907
|
renderMode: {
|
|
842
908
|
get(): RenderMode;
|
|
843
909
|
set(mode: RenderMode): Promise<RenderMode>;
|