partforge 0.168.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
@@ -198,16 +198,27 @@ second>` (a model with no playing animation turns until the first camera grab,
198
198
  never under `prefers-reduced-motion`), `fill: 0.8` (frame the model so its
199
199
  bounding box, as seen from the opening view, spans that fraction of the preview
200
200
  along its tighter axis — larger in a small card; with a turntable the model swept
201
- a full turn is framed, so it never clips as it turns and a round part still fills; the model is
202
- centred on its geometry, so a lopsided part turns about its hub), `camera: "default"` (drop the carried camera
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
203
  and projection and keep the file's own framing; a carried render mode and
204
204
  environment still apply) and `renderMode: "cad" | "realistic"` (the opening
205
- mode, over any carried one; it can change afterwards). An unknown option key
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
206
208
  throws. A baked animation always plays as authored — it
207
209
  loops, or plays once and holds its end pose. Every handle carries `drawn`, which
208
210
  resolves after the first frame showing the meshes, so a host can cross-fade from
209
211
  a still image.
210
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
+
211
222
  ```js
212
223
  const glb = await runtime.exportPreview(); // Uint8Array: the view on screen
213
224
  mount(part, { createWorker, elements, preview: glb }); // drawn at once, replaced by the build
@@ -218,6 +229,8 @@ const card = mountPreview(cardStage, { // a landing-page card
218
229
  zoom: false, pan: false, turntable: 14, fill: 0.8, camera: "default", renderMode: "cad",
219
230
  });
220
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().
221
234
  ```
222
235
 
223
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.168.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",
@@ -49,17 +49,24 @@
49
49
  // - `turntable: <degrees per second>` (default 0, off) — a file with no
50
50
  // playing animation (a still file, or `animate: false`) turns about the
51
51
  // vertical off the viewer's frame loop. Never under reduced motion; the
52
- // first camera grab — a canvas drag or the view cube — stops it for good.
53
- // Dragging an animated model orbits the camera and the animation plays on.
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.
54
55
  // - `fill: <0 < f <= 1>` — the opening framing (and the viewer's reframe)
55
56
  // places the camera, along the kept view direction, so the model's posed
56
57
  // bounding box as projected on screen spans that fraction of the viewport
57
58
  // along its limiting axis. A turning model is framed swept a full turn
58
59
  // about the vertical (its farthest vertex from the axis), so the turntable
59
- // never clips and a round part still fills. The model is centred on its
60
- // horizontal vertex centroid (a lopsided part turns about its hub); a baked
61
- // animation that moves parts outside the rest box can. Unset, the viewer's
62
- // fixed framing distance, as before.
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.
63
70
  // - `camera: "default"` — the camera and projection of `viewerState` and of
64
71
  // every setViewerState are dropped: the mount opens, and stays, at the
65
72
  // file's own default framing. Their render mode and environment still apply.
@@ -67,8 +74,32 @@
67
74
  // over any carried render mode. After that it is an ordinary mode —
68
75
  // setViewerState, renderMode.set and the view style control change it.
69
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.
70
84
  // Any other key throws (an option this version does not know is a host bug).
71
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
+ //
72
103
  // `handle.drawn` resolves after the first frame that includes the meshes has
73
104
  // rendered (so a host can cross-fade from a still image without a flash). It
74
105
  // rejects with `ready`'s error when the file will not decode, and rejects if
@@ -105,9 +136,10 @@ function flag(name, value, fallback) {
105
136
  const KNOWN_OPTIONS = new Set([
106
137
  "glb", "elements", "viewerState", "animate",
107
138
  "chrome", "background", "contactShadow", "zoom", "pan", "turntable", "fill", "camera", "renderMode",
139
+ "autostart",
108
140
  ]);
109
141
 
110
- function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode }) {
142
+ function presentation({ chrome, background, contactShadow, zoom, pan, turntable, fill, camera, renderMode, autostart }) {
111
143
  if (fill !== undefined && !(typeof fill === "number" && fill > 0 && fill <= 1)) {
112
144
  throw new TypeError("mountPreview: fill must be a number above 0 and at most 1");
113
145
  }
@@ -124,30 +156,47 @@ function presentation({ chrome, background, contactShadow, zoom, pan, turntable,
124
156
  fill: fill ?? null,
125
157
  defaultCamera: oneOf("camera", camera, ["carry", "default"], "carry") === "default",
126
158
  renderMode: oneOf("renderMode", renderMode, ["cad", "realistic"], null),
159
+ autostart: flag("autostart", autostart, true),
127
160
  };
128
161
  }
129
162
 
130
163
  // Turn the camera about the vertical at `degPerSec` until the first camera grab.
131
164
  // It turns through viewer.orbitBy, which itself announces a camera grab — so the
132
165
  // turntable's own steps are told apart from the user's by a flag held across
133
- // the call. Returns stop().
134
- function startTurntable(viewer, container, degPerSec) {
135
- let turning = false, stopped = false;
136
- const offFrame = viewer.onFrame((dt) => {
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) {
137
174
  // orbitBy's rate: a drag the full stage height is a full turn.
138
175
  const dx = ((degPerSec * dt) / 360) * Math.max(1, container.clientHeight || 1);
139
176
  if (!(dx > 0)) return;
140
177
  turning = true;
141
178
  try { viewer.orbitBy(dx, 0); } finally { turning = false; }
142
- });
143
- const offGrab = viewer.onCameraStart(() => { if (!turning) stop(); });
144
- function stop() {
145
- if (stopped) return;
146
- stopped = true;
147
- offFrame();
148
- offGrab();
149
179
  }
150
- return stop;
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
+ };
151
200
  }
152
201
 
153
202
  function previewPart({ meshes, view, meta }) {
@@ -192,6 +241,8 @@ export function mountPreview(container, options = {}) {
192
241
  if (opts.renderMode) carried = { ...(carried ?? {}), renderMode: opts.renderMode };
193
242
  let live = null; // { viewer, view } once the GLB has decoded
194
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();
195
246
  const cleanups = [];
196
247
  const modeListeners = new Set();
197
248
  const envListeners = new Set();
@@ -220,11 +271,15 @@ export function mountPreview(container, options = {}) {
220
271
  const view = decoded.view ?? "preview";
221
272
  const reducedMotion = typeof matchMedia === "function" && matchMedia("(prefers-reduced-motion: reduce)").matches;
222
273
  const plays = !!decoded.animation && animate !== false;
223
- const turns = !plays && opts.turntable > 0 && !reducedMotion;
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;
224
279
  const viewer = createViewer(container, previewPart({ ...decoded, view }), {
225
280
  transparent: opts.transparent, contactShadow: opts.contactShadow, zoom: opts.zoom, pan: opts.pan,
226
281
  // A turning model is framed swept a full turn, so no angle of it clips.
227
- frameFill: opts.fill, frameSwept: turns,
282
+ frameFill: opts.fill, frameSwept: sweeps,
228
283
  });
229
284
  cleanups.push(() => viewer.dispose());
230
285
  let tooltip = null, toolbar = null, viewStyle = null;
@@ -246,23 +301,70 @@ export function mountPreview(container, options = {}) {
246
301
  cleanups.push(viewer.onContextLost(relay(lostListeners)));
247
302
  for (const m of decoded.meshes) viewer.setSubGeometry(m.name, m);
248
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();
249
307
  live = { viewer, view };
250
308
  applyState(carried);
251
309
  if (!active) viewer.setActive(false);
252
310
  const offRendered = viewer.onRendered(() => { offRendered(); resolveDrawn(); });
253
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
+ };
254
329
  if (plays) {
255
330
  player = createPreviewPlayer(viewer, decoded.animation, { reducedMotion });
256
331
  const own = player;
257
332
  cleanups.push(() => own.dispose());
258
- 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()
259
336
  if (!reducedMotion && toolbar) {
260
337
  const playButton = attachPreviewPlayButton(own, { toolbar, before: viewStyle.element }, { tooltip });
261
338
  cleanups.push(() => playButton.detach());
262
339
  }
263
340
  } else if (turns) {
264
- cleanups.push(startTurntable(viewer, container, opts.turntable));
341
+ const own = turntable = createTurntable(viewer, container, opts.turntable, motionChanged);
342
+ cleanups.push(() => own.dispose());
343
+ if (opts.autostart) turntable.start();
265
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
+ };
266
368
  });
267
369
  ready.catch(rejectDrawn); // the host awaits it; never an unhandled rejection
268
370
 
@@ -293,6 +395,8 @@ export function mountPreview(container, options = {}) {
293
395
  rejectDrawn(new Error("mountPreview: disposed before the preview was drawn")); // a no-op once drawn
294
396
  live = null;
295
397
  player = null; // `animation` answers null once disposed (the cleanup below disposes it)
398
+ motion = null;
399
+ motionListeners.clear();
296
400
  const errors = [];
297
401
  while (cleanups.length) {
298
402
  try { cleanups.pop()(); } catch (error) { errors.push(error); }
@@ -305,6 +409,8 @@ export function mountPreview(container, options = {}) {
305
409
  get animation() {
306
410
  return player ? { state: player.state, pause: player.pause, resume: player.resume, replay: player.replay } : null;
307
411
  },
412
+ // null before ready and once disposed; present for every decoded file.
413
+ get motion() { return motion; },
308
414
  getViewerState,
309
415
  setViewerState(next) {
310
416
  carried = carry(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
  }
@@ -1928,6 +1928,7 @@ export function createViewer(container, part, {
1928
1928
 
1929
1929
  // --- show / hide assembly -------------------------------------------------
1930
1930
  const _box = new THREE.Box3();
1931
+ const _origin = new THREE.Vector3();
1931
1932
  const _posedBox = new THREE.Box3();
1932
1933
  const _frameDir = new THREE.Vector3();
1933
1934
 
@@ -1967,8 +1968,10 @@ export function createViewer(container, part, {
1967
1968
  if (_frameDir.lengthSq() === 0) _frameDir.set(1, 1, 1);
1968
1969
  _frameDir.normalize();
1969
1970
  const filled = fill?.(_frameDir);
1970
- activeCamera.position.copy(_frameDir).multiplyScalar(filled ? filled.distance : r * 2.6 + 6);
1971
- controls.target.set(0, 0, 0);
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);
1972
1975
  // Framing under ortho is a frustum, not a distance — without this the
1973
1976
  // reframe button moves the camera and nothing visibly changes. A fill
1974
1977
  // framing sizes the frustum to the projected box directly (the sync
@@ -2001,6 +2004,16 @@ export function createViewer(container, part, {
2001
2004
  // Null when unset, or when the container has no size yet (a hidden or
2002
2005
  // unlaid-out stage): frameTo then falls back to the fixed distance, and the
2003
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.
2004
2017
  const SWEEP_SEGMENTS = 48;
2005
2018
  const _sweepV = new THREE.Vector3();
2006
2019
  // The farthest horizontal distance of any posed vertex of `names` from the
@@ -2037,12 +2050,101 @@ export function createViewer(container, part, {
2037
2050
  }
2038
2051
  if (n > 0) center.set(sx / n, sy / n, center.z);
2039
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
+
2040
2141
  function fillFraming(box, names, center) {
2041
2142
  if (!frameFill) return null;
2042
2143
  const w = container.clientWidth, h = container.clientHeight;
2043
2144
  if (!(w > 0 && h > 0)) return null;
2044
2145
  const aspect = w / h;
2045
2146
  pivot.updateMatrix();
2147
+ if (!frameSwept) return centredFraming(posedVertices(names, center), aspect);
2046
2148
  // The box corners about the framing centre (no longer the box's own centre).
2047
2149
  let points = [];
2048
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]) {
package/types/index.d.ts CHANGED
@@ -821,19 +821,28 @@ export interface MountPreviewOptions {
821
821
  /**
822
822
  * Degrees per second (0.168.0+; default 0, off): a file with no playing animation
823
823
  * turns about the vertical. Never under reduced motion; the first camera grab
824
- * (a canvas drag or the view cube) stops it for good.
824
+ * (a canvas drag or the view cube) stops it until `motion.restart()`.
825
825
  */
826
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;
827
833
  /**
828
834
  * 0 < fill <= 1 (0.168.0+): the opening framing (and the viewer's reframe) puts the
829
835
  * camera, along the opening view direction, so the model's bounding box as projected
830
836
  * on screen spans this fraction of the viewport along its limiting axis. With a
831
837
  * `turntable` (and no baked animation) the model swept a full turn is framed — its
832
838
  * farthest vertex from the vertical axis, over the box's height — so the turn never
833
- * clips and a round part still fills. With `fill` the model is centred on its
834
- * horizontal vertex centroid (a lopsided part turns about its hub), and nothing
835
- * leaves the viewport; a baked animation that moves parts outside the box may. Unset, the
836
- * fixed framing distance.
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.
837
846
  */
838
847
  fill?: number;
839
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"`. */
@@ -876,6 +885,25 @@ export interface PreviewRuntime {
876
885
  /** Play a finished one-time animation again from the start (its camera cues included); a no-op unless `state().status` is `"done"`. */
877
886
  replay(): void;
878
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;
879
907
  renderMode: {
880
908
  get(): RenderMode;
881
909
  set(mode: RenderMode): Promise<RenderMode>;