partforge 0.128.0 → 0.130.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.
@@ -259,10 +259,32 @@ Rules (all lint-enforced):
259
259
  - Couple motions through `derive` (animate one master param; derive the rest),
260
260
  not by tracking dependent params separately.
261
261
  - `camera` cues use the seven canonical angles (`iso front back top bottom
262
- left right`). One mechanism per animation: an animation-level name (an intro
263
- cue at t=0), an animation-level `[[t, angle], …]` list, or per-step names.
262
+ left right`) or any view-cube orientation — an edge or corner named
263
+ vertical, then depth, then side (`top-front-left`, `top-back-right`,
264
+ `front-left`, `bottom-back-right`, …; `top-front-right` is `iso`). One
265
+ mechanism per animation: an animation-level name (an intro cue at t=0), an
266
+ animation-level `[[t, angle], …]` list, or per-step names.
264
267
  Cues fire during play only — scrubbing never moves the camera — and a user
265
268
  orbit disarms the remaining cues for that run.
269
+ - **A camera move takes as long as its cue asks**: a per-step `camera` sweeps
270
+ across that step's whole `duration`, and a listed cue sweeps until the next
271
+ cue (or the end). The move that settles the camera BEFORE playback starts —
272
+ the first cue, or the governing one when play resumes mid-timeline — is
273
+ always a short swing, because params wait for it. So a slow orbit timed to a
274
+ motion is a camera-only opening step (the starting angle) followed by the
275
+ moving step carrying the destination angle.
276
+ - **To turn the view, move the camera — never rotate the part.** A "spin"
277
+ param that rotates the geometry for a turntable effect looks right in CAD
278
+ mode but is wrong in realistic mode: the floor, shadow and backdrop stay put
279
+ in the world, so the part visibly slides around on them. Use cues instead:
280
+
281
+ ```js
282
+ steps: [
283
+ { label: "Start", camera: "top-front-left", duration: 0.3 }, // establishing angle
284
+ { label: "Open lid", camera: "iso", duration: 1.5, // orbits to iso over 1.5 s
285
+ tracks: { lidOpen: [[0, 0], [1, 100]] } },
286
+ ]
287
+ ```
266
288
  - Playback drives params through the real param pipeline: a **pose-only**
267
289
  param (feeds only rigid placement — see "Caching & determinism" below) plays
268
290
  at frame rate; anything else rebuilds best-effort at worker cadence. `lint`
@@ -331,7 +353,10 @@ meta: { title: "…", units: "mm", environment: "studio" },
331
353
  colour, flattened so dark materials stay readable. **Realistic** mode — the
332
354
  viewer's toggle — shows the full material under environment lighting, with a
333
355
  ground and soft shadow and no feature lines. The part never moves when the
334
- mode changes.
356
+ mode changes. The ground is placed under the part when a view is shown and
357
+ after each edit; while an animation plays or is scrubbed it stays where it is
358
+ (dropping lower only if the part would otherwise sink into it), so a motion
359
+ that changes the part's footprint never slides the floor around under it.
335
360
 
336
361
  **3D-print layer lines** (`pla-print`, `petg-print`) run perpendicular to the
337
362
  **export** pose's +Z — the way the part will be printed, not the way it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "partforge",
3
- "version": "0.128.0",
3
+ "version": "0.130.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",
@@ -504,6 +504,12 @@ export function attachAnimationControls(viewer, part, {
504
504
  // write params, or it would dirty the regen loop for a display-only fade.
505
505
  // First write for this run also remembers what the user's params were, so
506
506
  // Reset can put them back.
507
+ // Anything the animation moves holds the realistic floor where it is (see
508
+ // viewer.js placeGround): a track that rebuilds geometry re-shows the
509
+ // assembly every delivery, and the floor must not chase each frame's
510
+ // bounds. Released by a user edit, reset/stop, or a view switch — NOT by
511
+ // reaching the end, which would just move the jump to the last frame.
512
+ if (Object.keys(r.values).length || Object.keys(r.opacity ?? {}).length) viewer.holdGround?.(true);
507
513
  if (Object.keys(r.values).length) {
508
514
  if (snapshot == null) snapshot = getParamValues(current.trackedKeys);
509
515
  applyValues(r.values);
@@ -513,7 +519,10 @@ export function attachAnimationControls(viewer, part, {
513
519
  for (const [n, v] of Object.entries(r.opacity ?? {})) viewer.setSubPartOpacity?.(n, v);
514
520
  if (r.cue) {
515
521
  viewer.tweenCameraTo(r.cue.view, {
516
- duration: tweenDuration,
522
+ // An intro gates playback, so it is always the short tween; a cue
523
+ // crossed mid-timeline sweeps for as long as the cue asks (its step,
524
+ // or until the next cue) — see normalizeAnimation.
525
+ duration: reducedMotion ? 0 : r.status === "intro" ? tweenDuration : (r.cue.sweep ?? tweenDuration),
517
526
  // An intro cue gates playback until the tween settles; mid-timeline
518
527
  // cues overlap playback and need no completion signal.
519
528
  onComplete: r.status === "intro" ? () => guarded(() => playback.introDone()) : undefined,
@@ -536,6 +545,9 @@ export function attachAnimationControls(viewer, part, {
536
545
  }
537
546
 
538
547
  function doReset() {
548
+ // Before the snapshot is re-applied: the rebuild that restores the user's
549
+ // params is an ordinary placement, so the floor comes back to the part.
550
+ viewer.holdGround?.(false);
539
551
  playback?.reset();
540
552
  viewer.cancelCameraTween();
541
553
  viewer.clearSubPartOpacities?.();
@@ -811,6 +823,7 @@ export function attachAnimationControls(viewer, part, {
811
823
  // pause playback rather than fight over them.
812
824
  notifyUserEdit() {
813
825
  disarmAutoplay();
826
+ viewer.holdGround?.(false); // a user edit places the floor again, as without animation
814
827
  viewer.cancelCameraTween();
815
828
  playback?.userEdited();
816
829
  syncUi();
@@ -851,6 +864,7 @@ export function attachAnimationControls(viewer, part, {
851
864
  if (status !== "playing" && status !== "intro") guarded(() => playback.play());
852
865
  },
853
866
  detach() {
867
+ viewer.holdGround?.(false);
854
868
  offFrame();
855
869
  offOrbit();
856
870
  viewer.clearSubPartOpacities?.();
@@ -62,10 +62,22 @@ export function normalizeAnimation(name, spec) {
62
62
  const totalDuration = steps.reduce((sum, s) => sum + s.duration, 0) || 1;
63
63
  let acc = 0;
64
64
  const stepStarts = steps.map((s) => { const t = acc / totalDuration; acc += s.duration; return t; });
65
+ // Each cue carries `sweep`: how many seconds its camera move takes when
66
+ // playback crosses it. A per-step cue sweeps across its own step, and a
67
+ // cue-list entry until the next cue (or the end), so a slow orbit can be timed
68
+ // to the motion it frames. An intro cue — the one play() settles on before
69
+ // params move — ignores it and uses the driver's short fixed tween, since it
70
+ // gates playback and a long one would read as a stall.
65
71
  let cues;
66
- if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera }];
67
- else if (Array.isArray(spec.camera)) cues = spec.camera.map(([t, view]) => ({ t, view }));
68
- else cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera }] : []));
72
+ // A lone angle name is only ever an intro, so it has no sweep of its own.
73
+ if (typeof spec.camera === "string") cues = [{ t: 0, view: spec.camera, sweep: null }];
74
+ else if (Array.isArray(spec.camera)) {
75
+ cues = spec.camera.map(([t, view], i, all) => ({
76
+ t, view, sweep: ((all[i + 1]?.[0] ?? 1) - t) * totalDuration,
77
+ }));
78
+ } else {
79
+ cues = steps.flatMap((s, i) => (s.camera ? [{ t: stepStarts[i], view: s.camera, sweep: s.duration }] : []));
80
+ }
69
81
  // Keys with at least one usable keyframe list in any step, for one field
70
82
  // ("tracks" or "opacity"). Shares usableKeyframes with segmentsFor — the
71
83
  // single rule both must agree on (see the comment on usableKeyframes).
@@ -200,6 +212,7 @@ export function createPlayback(anim) {
200
212
  let firedCueT = -1; // cues with t <= firedCueT already fired this run
201
213
  let pendingCueT = null; // cue handed to an in-flight intro tween, not yet settled
202
214
  let stopAt = null; // stepNext/playStep pause playback on reaching this t
215
+ let sweepingCue = null; // the cue tick() last fired, whose camera sweep may still be moving
203
216
 
204
217
  const snapshot = (cue = null) => ({ t, status, ...evaluate(anim, t), cue });
205
218
 
@@ -227,8 +240,24 @@ export function createPlayback(anim) {
227
240
  stopAt = null;
228
241
  return begin();
229
242
  }
243
+ // A stop mid-sweep cancels the camera where it is. Un-fire that cue so the
244
+ // next play() re-honors it (as an intro) instead of resuming with the camera
245
+ // stranded partway; a sweep that has already landed stays fired, so an
246
+ // ordinary pause/resume costs no extra tween.
247
+ // Only a cue crossed DURING playback sweeps; an intro lands its pose fully
248
+ // before params move, so it is never abandoned here.
249
+ function abandonSweep() {
250
+ const c = sweepingCue;
251
+ sweepingCue = null;
252
+ if (c && t < c.t + c.sweep / anim.totalDuration) {
253
+ let prev = -1;
254
+ for (const o of anim.cues) if (o.t < c.t) prev = o.t;
255
+ firedCueT = prev;
256
+ }
257
+ }
258
+
230
259
  function pause() {
231
- if (status === "playing" || status === "intro") status = "paused";
260
+ if (status === "playing" || status === "intro") { status = "paused"; abandonSweep(); }
232
261
  pendingCueT = null; // an unsettled intro cue is abandoned, so resume re-issues it
233
262
  return snapshot();
234
263
  }
@@ -246,6 +275,7 @@ export function createPlayback(anim) {
246
275
  stopAt = null;
247
276
  firedCueT = -1; // a later play() re-honors the cue governing the new position
248
277
  pendingCueT = null;
278
+ sweepingCue = null;
249
279
  return snapshot();
250
280
  }
251
281
  function playStep(i) {
@@ -254,6 +284,7 @@ export function createPlayback(anim) {
254
284
  stopAt = idx + 1 < anim.steps.length ? anim.stepStarts[idx + 1] : 1;
255
285
  firedCueT = -1;
256
286
  pendingCueT = null;
287
+ sweepingCue = null;
257
288
  return begin();
258
289
  }
259
290
  function stepNext() {
@@ -264,11 +295,11 @@ export function createPlayback(anim) {
264
295
  return playStep(Math.max(0, stepIndexAt(anim, t) - 1));
265
296
  }
266
297
  function reset() {
267
- t = 0; status = "idle"; stopAt = null; firedCueT = -1; pendingCueT = null; armed = true;
298
+ t = 0; status = "idle"; stopAt = null; firedCueT = -1; pendingCueT = null; armed = true; sweepingCue = null;
268
299
  return snapshot();
269
300
  }
270
301
  function disarmCues() { armed = false; }
271
- function userEdited() { if (status === "playing" || status === "intro") status = "paused"; }
302
+ function userEdited() { if (status === "playing" || status === "intro") { status = "paused"; abandonSweep(); } }
272
303
 
273
304
  function tick(dt) {
274
305
  if (status !== "playing" || !(dt > 0)) return null;
@@ -277,17 +308,21 @@ export function createPlayback(anim) {
277
308
  // animation, so the two rarely coexist — but when they do, an explicit
278
309
  // "play this step" must still stop where it was told to, rather than being
279
310
  // swallowed by the wrap and running forever.
311
+ let stoppedAtBoundary = false;
280
312
  if (stopAt != null && t >= stopAt) {
281
- t = stopAt; stopAt = null; status = "paused";
313
+ t = stopAt; stopAt = null; status = "paused"; stoppedAtBoundary = true;
282
314
  } else if (anim.loop) {
283
315
  if (t >= 1) t -= Math.floor(t);
284
316
  } else if (t >= 1) {
285
317
  t = 1; status = "done";
286
318
  }
287
319
  let cue = null;
288
- if (armed) {
320
+ // Stopping on a step boundary does not start the next step's camera move:
321
+ // that cue belongs to the step that has not played yet, and would otherwise
322
+ // sweep on while playback sits paused. Playing that step intros to it.
323
+ if (armed && !stoppedAtBoundary) {
289
324
  for (const c of anim.cues) if (c.t <= t && c.t > firedCueT) cue = c;
290
- if (cue) firedCueT = cue.t;
325
+ if (cue) { firedCueT = cue.t; sweepingCue = cue; }
291
326
  }
292
327
  return snapshot(cue);
293
328
  }
@@ -12,7 +12,7 @@
12
12
  // letting the author wonder why nothing plays.
13
13
  import { err, note } from "./finding.js";
14
14
  import { EASINGS } from "../animation.js";
15
- import { CANONICAL_VIEWS } from "../view-angles.js";
15
+ import { CANONICAL_VIEWS, CAMERA_CUE_VIEWS } from "../view-angles.js";
16
16
  import { probeSubPartPose } from "../pose-probe-core.js";
17
17
  import { resolveDerived } from "../derive.js";
18
18
  import { desugar } from "../panel/legacy.js";
@@ -372,7 +372,9 @@ export const ANIMATION_RULES = [
372
372
  id: "animation-camera-invalid",
373
373
  run: ({ part }) => {
374
374
  const out = [];
375
- const badName = (v) => typeof v !== "string" || !CANONICAL_VIEWS.includes(v);
375
+ const badName = (v) => typeof v !== "string" || !CAMERA_CUE_VIEWS.includes(v);
376
+ // The seven canonical names plus the view cube's corners and edges.
377
+ const angles = `${CANONICAL_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left or top-back-right`;
376
378
  for (const { name, a, base } of animEntries(part)) {
377
379
  const stepCameras = Array.isArray(a.steps)
378
380
  ? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
@@ -386,8 +388,8 @@ export const ANIMATION_RULES = [
386
388
  for (const [cam, i] of stepCameras) {
387
389
  if (badName(cam)) {
388
390
  out.push(err("animation-camera-invalid",
389
- `animation "${name}" step ${i} camera "${cam}" is not a canonical angle`,
390
- `Camera cues use the canonical angles: ${CANONICAL_VIEWS.join(", ")}.`,
391
+ `animation "${name}" step ${i} camera "${cam}" is not a camera angle`,
392
+ `Camera cues use ${angles}.`,
391
393
  `${base}.steps[${i}].camera`));
392
394
  }
393
395
  }
@@ -397,8 +399,8 @@ export const ANIMATION_RULES = [
397
399
  if (typeof a.camera === "string") {
398
400
  if (badName(a.camera)) {
399
401
  out.push(err("animation-camera-invalid",
400
- `animation "${name}" camera "${a.camera}" is not a canonical angle`,
401
- `Camera cues use the canonical angles: ${CANONICAL_VIEWS.join(", ")}.`,
402
+ `animation "${name}" camera "${a.camera}" is not a camera angle`,
403
+ `Camera cues use ${angles}.`,
402
404
  `${base}.camera`));
403
405
  }
404
406
  } else if (Array.isArray(a.camera)) {
@@ -409,7 +411,7 @@ export const ANIMATION_RULES = [
409
411
  if (!wellFormed || !sorted) {
410
412
  out.push(err("animation-camera-invalid",
411
413
  `animation "${name}" has an invalid camera cue list`,
412
- `Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${CANONICAL_VIEWS.join(", ")}.`,
414
+ `Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${angles}.`,
413
415
  `${base}.camera`));
414
416
  }
415
417
  } else {
@@ -19,7 +19,7 @@ export const DEFAULT_ENVIRONMENT_ID = "studio";
19
19
 
20
20
  export const ENVIRONMENTS = {
21
21
  studio: {
22
- id: "studio", label: "Studio", exposure: 0.8,
22
+ id: "studio", label: "Studio", exposure: 0.64,
23
23
  hdr: "env-studio.jpg",
24
24
  // Paper 001's fibre relief (normal + roughness) does the work: its colour
25
25
  // map is nearly flat, so without them the sweep read as soft blotches up
@@ -35,7 +35,7 @@ export const ENVIRONMENTS = {
35
35
  blurriness: 0.3,
36
36
  },
37
37
  workshop: {
38
- id: "workshop", label: "Workshop", exposure: 0.88,
38
+ id: "workshop", label: "Workshop", exposure: 0.7,
39
39
  hdr: "env-workshop.jpg",
40
40
  // Unfinished maple: Poly Haven "Oak Veneer 01" lightened and desaturated
41
41
  // at bake time, with its normal map at a gentle strength and NO roughness
@@ -47,7 +47,7 @@ export const ENVIRONMENTS = {
47
47
  },
48
48
  },
49
49
  "print-bed": {
50
- id: "print-bed", label: "Print bed", exposure: 0.42,
50
+ id: "print-bed", label: "Print bed", exposure: 0.34,
51
51
  // The studio photo re-baked with --contrast: a darker, harsher room, like
52
52
  // the inside of a printer's enclosure.
53
53
  hdr: "env-print-bed.jpg",
@@ -65,7 +65,7 @@ export const ENVIRONMENTS = {
65
65
  },
66
66
  },
67
67
  outdoor: {
68
- id: "outdoor", label: "Outdoor", exposure: 0.72,
68
+ id: "outdoor", label: "Outdoor", exposure: 0.58,
69
69
  hdr: "env-outdoor.jpg",
70
70
  // Tinted to a warm mid-grey: the overcast sky lights an untinted concrete
71
71
  // map near-white and blue. Exposed-aggregate concrete with its own normal
@@ -81,6 +81,13 @@ function buildOrientations() {
81
81
  export const ORIENTATIONS = buildOrientations();
82
82
  export const ORIENTATION_IDS = Object.keys(ORIENTATIONS);
83
83
 
84
+ // Every angle an animation camera cue may name: the seven canonical names first,
85
+ // then the view cube's other orientations (so "top-front-left" is iso seen from
86
+ // the other front corner). cameraPoseForView resolves all of them; this list is
87
+ // what lint holds cues to. Kept apart from CANONICAL_VIEWS for the reason the
88
+ // comment on ORIENTATIONS gives.
89
+ export const CAMERA_CUE_VIEWS = [...new Set([...CANONICAL_VIEWS, ...ORIENTATION_IDS])];
90
+
84
91
  const norm = (v) => {
85
92
  const l = Math.hypot(v[0], v[1], v[2]) || 1;
86
93
  return [v[0] / l, v[1] / l, v[2] / l];
@@ -794,15 +794,28 @@ export function createViewer(container, part) {
794
794
  // The ground comes to the part: it sits at the bottom of what is visible and
795
795
  // is sized from it. Only on showAssembly (and on entering the mode) — an
796
796
  // animated pose change moves the shadow, never the floor.
797
+ //
798
+ // An animation that REBUILDS geometry re-shows the assembly on every
799
+ // delivery, though, and re-deriving the floor from each frame's bounds slides
800
+ // it under the part as the bounds shift (feedback #138: a lid swinging open
801
+ // dragged the floor back with it). So while the animation driver holds the
802
+ // ground, a placement reuses the last one and may only LOWER it — never slide
803
+ // it sideways or resize it, and never lift it into the part. The hold is
804
+ // viewer-wide rather than per rig, so an environment switch mid-playback
805
+ // lands its floor in the same place.
806
+ let groundHeld = false;
807
+ let lastGround = null;
808
+ function holdGround(on) { groundHeld = !!on; }
797
809
  function placeGround({ force = false } = {}) {
798
810
  if (!realisticRig) return;
799
811
  const b = getVisibleWorldBounds(); // shared Box3: read it out before anything else runs
800
812
  if (b.isEmpty()) return;
801
813
  const center = b.getCenter(new THREE.Vector3());
802
814
  const size = b.getSize(new THREE.Vector3());
803
- realisticRig.setGround({
804
- y: b.min.y, centerX: center.x, centerZ: center.z, radius: size.length() / 2, footprintMm: Math.max(size.x, size.z),
805
- });
815
+ lastGround = groundHeld && lastGround
816
+ ? { ...lastGround, y: Math.min(lastGround.y, b.min.y) }
817
+ : { y: b.min.y, centerX: center.x, centerZ: center.z, radius: size.length() / 2, footprintMm: Math.max(size.x, size.z) };
818
+ realisticRig.setGround(lastGround);
806
819
  shadowMovedAt = null; // this render is the full-resolution one
807
820
  renderShadow(undefined, { force });
808
821
  }
@@ -2658,6 +2671,7 @@ export function createViewer(container, part) {
2658
2671
  onFrame,
2659
2672
  onAssemblyChange: (cb) => { assemblyListeners.add(cb); return () => assemblyListeners.delete(cb); },
2660
2673
  tweenCameraTo,
2674
+ holdGround,
2661
2675
  cancelCameraTween,
2662
2676
  orbitBy,
2663
2677
  onCameraStart,
@@ -1,6 +1,7 @@
1
1
  import { writeFileSync, mkdirSync } from "node:fs";
2
2
  import { join, resolve, sep } from "node:path";
3
3
  import { safeName } from "../framework/safe-name.js";
4
+ import { ORIENTATIONS } from "../framework/view-angles.js";
4
5
  import { buildView } from "../framework/oracle/build.js";
5
6
  import { bounds } from "../framework/oracle/mesh.js";
6
7
 
@@ -23,6 +24,17 @@ export const RENDER_ANGLES = {
23
24
  };
24
25
  export const RENDER_VIEWS = Object.keys(RENDER_ANGLES);
25
26
 
27
+ // The view cube's other orientations (animation camera cues may name them, and
28
+ // a still defaults to its cue's angle), mapped from the viewer's Y-up world
29
+ // back into model space: world (x, y, z) -> model (x, -z, y).
30
+ export function renderAngle(name) {
31
+ if (Object.hasOwn(RENDER_ANGLES, name)) return RENDER_ANGLES[name];
32
+ const o = Object.hasOwn(ORIENTATIONS, name) ? ORIENTATIONS[name] : null;
33
+ if (!o) return null;
34
+ const toModel = ([x, y, z]) => [x, -z, y];
35
+ return { dir: toModel(o.dir), up: toModel(o.up) };
36
+ }
37
+
26
38
  const sub = (a, b) => [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
27
39
  const cross = (a, b) => [a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2], a[0] * b[1] - a[1] * b[0]];
28
40
  const dot = (a, b) => a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
@@ -93,8 +105,8 @@ export async function renderViews(kernel, part, view = Object.keys(part.views)[0
93
105
  const written = [];
94
106
 
95
107
  for (const angle of views) {
96
- const a = RENDER_ANGLES[angle];
97
- if (!a) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")})`);
108
+ const a = renderAngle(angle);
109
+ if (!a) throw new Error(`unknown angle "${angle}" (use: ${RENDER_VIEWS.join(", ")}, or a view-cube orientation such as top-front-left)`);
98
110
  // orthographic camera basis: zc toward camera, xc right, yc up
99
111
  const zc = norm(a.dir), xc = norm(cross(a.up, zc)), yc = cross(zc, xc);
100
112
  // Key direction (toward the light), placed over the viewer's shoulder — up and to
package/types/part.d.ts CHANGED
@@ -556,11 +556,22 @@ export type Keyframes = Array<[number, number]>;
556
556
  export type CanonicalView = "iso" | "front" | "back" | "left" | "right" | "top" | "bottom";
557
557
 
558
558
  /**
559
- * A camera cue angle. `partforge lint` rejects anything outside the canonical
560
- * seven (`animation-camera-invalid`), so the type says so too. Cues fire during
561
- * play only; scrubbing never moves the camera.
559
+ * The view cube's other orientations: its edges and corners, named vertical,
560
+ * then depth, then side. `"top-front-right"` is the same camera as `"iso"`.
562
561
  */
563
- export type CameraCue = CanonicalView;
562
+ export type ViewCubeOrientation =
563
+ | "top-front" | "top-back" | "top-left" | "top-right"
564
+ | "bottom-front" | "bottom-back" | "bottom-left" | "bottom-right"
565
+ | "front-left" | "front-right" | "back-left" | "back-right"
566
+ | "top-front-left" | "top-front-right" | "top-back-left" | "top-back-right"
567
+ | "bottom-front-left" | "bottom-front-right" | "bottom-back-left" | "bottom-back-right";
568
+
569
+ /**
570
+ * A camera cue angle: one of the canonical seven or a view-cube orientation.
571
+ * `partforge lint` rejects anything else (`animation-camera-invalid`). Cues fire
572
+ * during play only; scrubbing never moves the camera.
573
+ */
574
+ export type CameraCue = CanonicalView | ViewCubeOrientation;
564
575
 
565
576
  /** One step of a multi-step animation. Steps play in order; prev/next navigate them. */
566
577
  export interface AnimationStep {
@@ -586,7 +597,12 @@ export interface AnimationStep {
586
597
  * never affects params, export, measure, or verify.
587
598
  */
588
599
  opacity?: Record<string, Keyframes>;
589
- /** Swing the camera to this angle when the step begins. */
600
+ /**
601
+ * Swing the camera to this angle, starting when the step begins and taking
602
+ * the step's whole duration — so a slow orbit can be timed to the motion it
603
+ * frames. (Starting playback at this step instead makes a short move there
604
+ * before anything else runs.)
605
+ */
590
606
  camera?: CameraCue;
591
607
  }
592
608
 
@@ -604,7 +620,8 @@ export interface AnimationSpecCommon {
604
620
  loop?: boolean;
605
621
  /**
606
622
  * One mechanism per animation: an angle (an intro cue at t=0), a
607
- * `[[t, angle], …]` cue list, or per-step `camera` names.
623
+ * `[[t, angle], …]` cue list, or per-step `camera` names. A listed cue's
624
+ * camera move lasts until the next cue (or the end of the animation).
608
625
  */
609
626
  camera?: CameraCue | Array<[number, CameraCue]>;
610
627
  /**