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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.167.0",
3
+ "version": "0.169.0",
4
4
  "description": "Turn a declarative part definition into a parametric-CAD web app (three.js + Manifold/Replicad). Requires a Vite-based consumer.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -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, { glb, elements = {}, viewerState = null, animate = true } = {}) {
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
- let carried = viewerState ?? null; // applied once the viewer exists, and on every setViewerState
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
- viewer.setProjection(s.projection === "orthographic" && isFaceAlignedState(s.camera) ? "orthographic" : "perspective");
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 viewer = createViewer(container, previewPart({ ...decoded, view }));
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
- const tooltip = createTooltipPresenter({ id: null });
100
- cleanups.push(() => tooltip.dispose());
101
- const viewcube = attachViewcubeControls(viewer, { stage: container });
102
- cleanups.push(() => viewcube.detach());
103
- const toolbar = container.querySelector("#viewbar");
104
- const viewStyle = attachViewStyleControls(viewer, { stage: container, toolbar, anchor: viewcube.element }, { tooltip });
105
- cleanups.push(() => viewStyle.detach());
106
- cleanups.push(observeViewbarClear(container, toolbar));
107
- const chrome = attachViewerControls(viewer, elements.chrome ?? {}, { tooltip });
108
- cleanups.push(() => chrome.detach());
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
- if (decoded.animation && animate !== false) {
118
- const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
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
- player.start();
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(() => {}); // the host awaits it; never an unhandled rejection
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 ?? null;
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
- return {
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
- apply(0);
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 (!offFrame) return; // never played (not started, or reduced motion): nothing to undo
198
- offFrame(); offOrbit();
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
  }
@@ -163,7 +163,22 @@ export function captureCurrentFromScene(
163
163
  }
164
164
  }
165
165
 
166
- export function createViewer(container, part) {
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({ antialias: true, stencil: true });
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
- scene.background = new THREE.Color(THEME.dark.bg);
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 = new THREE.Color(THEME[currentTheme].bg);
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 = true; });
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
- activeCamera.position.copy(_frameDir.normalize().multiplyScalar(r * 2.6 + 6));
1884
- controls.target.set(0, 0, 0);
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
- if (projectionMode === "orthographic") syncOrthoToPerspectiveFraming();
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 = new THREE.Color(t.bg);
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>;