partforge 0.48.0 → 0.50.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.
@@ -1,9 +1,15 @@
1
- // Group 5 — the `animations` block (spec 2026-08-02-model-animation-design.md).
1
+ // Group 5 — the per-view `animations` blocks (specs
2
+ // 2026-08-02-model-animation-design.md, 2026-08-10-per-view-animations.md).
2
3
  // Everything but the last rule is static data validation: the block is pure
3
4
  // keyframe data by design, so lint can hold every track to the schema without
4
5
  // executing author code. `animation-track-rebuilds` is the exception — it runs
5
6
  // the geometry-free pose probe to classify each track as pose-only or
6
7
  // geometry-rebuilding, and reports the latter at the note tier.
8
+ //
9
+ // Animations are VIEW-OWNED: each block lives at `views.<view>.animations`, and
10
+ // every finding path says so. A legacy top-level `animations` key is ignored by
11
+ // the runtime, so `animation-not-in-view` reports it as a hard error rather than
12
+ // letting the author wonder why nothing plays.
7
13
  import { err, note } from "./finding.js";
8
14
  import { EASINGS } from "../animation.js";
9
15
  import { CANONICAL_VIEWS } from "../view-angles.js";
@@ -14,14 +20,23 @@ import { controlNodes } from "../panel/model.js";
14
20
 
15
21
  const isPlainObject = (x) => x !== null && typeof x === "object" && !Array.isArray(x);
16
22
 
17
- // [name, spec] pairs, only when the block is well-shaped enough to walk.
18
- const animEntries = (part) =>
19
- isPlainObject(part?.animations)
20
- ? Object.entries(part.animations).filter(([, a]) => isPlainObject(a))
21
- : [];
23
+ // [{ view, name, a, base }] entries, only when blocks are well-shaped enough to
24
+ // walk; `base` is the finding-path prefix `views.<view>.animations.<name>`.
25
+ const animEntries = (part) => {
26
+ const out = [];
27
+ if (!isPlainObject(part?.views)) return out;
28
+ for (const [view, v] of Object.entries(part.views)) {
29
+ if (!isPlainObject(v) || !isPlainObject(v.animations)) continue;
30
+ for (const [name, a] of Object.entries(v.animations)) {
31
+ if (isPlainObject(a)) out.push({ view, name, a, base: `views.${view}.animations.${name}` });
32
+ }
33
+ }
34
+ return out;
35
+ };
22
36
 
23
37
  // Steps in normalized-adjacent form for rule walks (does NOT validate — each
24
- // rule checks its own slice). A bare-tracks animation is one anonymous step.
38
+ // rule checks its own slice). A bare-tracks animation is one anonymous step;
39
+ // the spread carries `tracks` AND `opacity` through unchanged.
25
40
  const rawSteps = (a) => (Array.isArray(a.steps) ? a.steps.filter(isPlainObject) : [{ ...a, label: null }]);
26
41
 
27
42
  // The control descriptor ranges, for value-in-range checks. Walks the shared
@@ -48,54 +63,75 @@ const validKeyframes = (kf) =>
48
63
  && kf.every((e, i) => i === 0 || e[0] > kf[i - 1][0]);
49
64
 
50
65
  export const ANIMATION_RULES = [
66
+ {
67
+ // Clean break, not a deprecation: viewAnimations() reads only
68
+ // `views.<v>.animations`, so a top-level block animates nothing at all.
69
+ id: "animation-not-in-view",
70
+ run: ({ part }) => (part?.animations === undefined ? [] : [
71
+ err("animation-not-in-view",
72
+ "`animations` moved into views — a top-level block is ignored at runtime",
73
+ "Declare each animation under its owning view: `views.<name>.animations = { <anim>: { … } }`. The transport bar shows only the active view's animations.",
74
+ "animations"),
75
+ ]),
76
+ },
51
77
  {
52
78
  id: "animations-not-object",
53
79
  run: ({ part }) => {
54
- if (part?.animations === undefined) return [];
55
- if (!isPlainObject(part.animations)) {
56
- return [err("animations-not-object", "`animations` is not a plain object",
57
- "Declare animations as `animations: { <name>: { duration, tracks } }` — see docs/AUTHORING-PARTS.md \"Animations\".",
58
- "animations")];
80
+ const out = [];
81
+ if (!isPlainObject(part?.views)) return out;
82
+ for (const [view, v] of Object.entries(part.views)) {
83
+ if (!isPlainObject(v) || v.animations === undefined) continue;
84
+ if (!isPlainObject(v.animations)) {
85
+ out.push(err("animations-not-object", `view "${view}" \`animations\` is not a plain object`,
86
+ "Declare animations as `views.<name>.animations = { <anim>: { duration, tracks } }` — see docs/AUTHORING-PARTS.md \"Animations\".",
87
+ `views.${view}.animations`));
88
+ continue;
89
+ }
90
+ for (const [name, a] of Object.entries(v.animations)) {
91
+ if (isPlainObject(a)) continue;
92
+ out.push(err("animations-not-object", `animation "${name}" is not a plain object`,
93
+ "Each animations entry must be an object with `duration` + `tracks`/`opacity`, or `steps`.",
94
+ `views.${view}.animations.${name}`));
95
+ }
59
96
  }
60
- return Object.entries(part.animations)
61
- .filter(([, a]) => !isPlainObject(a))
62
- .map(([name]) => err("animations-not-object", `animation "${name}" is not a plain object`,
63
- "Each animations entry must be an object with `duration` + `tracks`, or `steps`.",
64
- `animations.${name}`));
97
+ return out;
65
98
  },
66
99
  },
67
100
  {
68
101
  id: "animation-tracks-or-steps",
69
102
  run: ({ part }) => {
70
103
  const out = [];
71
- for (const [name, a] of animEntries(part)) {
72
- const hasTracks = a.tracks !== undefined;
104
+ for (const { name, a, base } of animEntries(part)) {
105
+ // A single-phase animation may drive params (`tracks`), sub-part
106
+ // opacity, or both — either one puts it in the non-stepped form.
107
+ const hasSingle = a.tracks !== undefined || a.opacity !== undefined;
73
108
  const hasSteps = a.steps !== undefined;
74
- if (hasTracks === hasSteps) {
109
+ if (hasSingle === hasSteps) {
75
110
  out.push(err("animation-tracks-or-steps",
76
- `animation "${name}" must have exactly one of \`tracks\` or \`steps\``,
77
- "A single-phase animation declares `tracks` directly; a stepped one declares `steps: [{ label, duration, tracks }]`. Never both, never neither.",
78
- `animations.${name}`));
111
+ `animation "${name}" must have exactly one of \`tracks\`/\`opacity\` or \`steps\``,
112
+ "A single-phase animation declares `tracks` and/or `opacity` with a `duration`; a stepped one declares `steps: []`. Never both forms, never neither.",
113
+ base));
79
114
  continue;
80
115
  }
81
116
  if (hasSteps && (!Array.isArray(a.steps) || a.steps.length === 0 || !a.steps.every(isPlainObject))) {
82
117
  out.push(err("animation-tracks-or-steps",
83
118
  `animation "${name}" has an empty or malformed \`steps\` array`,
84
119
  "`steps` must be a non-empty array of `{ label, duration, tracks }` objects.",
85
- `animations.${name}.steps`));
120
+ `${base}.steps`));
86
121
  continue;
87
122
  }
88
123
  const steps = rawSteps(a);
89
- const trackful = (s) => isPlainObject(s.tracks) && Object.keys(s.tracks).length > 0;
90
- if (!steps.some(trackful)) {
124
+ const animated = (s) => (isPlainObject(s.tracks) && Object.keys(s.tracks).length > 0)
125
+ || (isPlainObject(s.opacity) && Object.keys(s.opacity).length > 0);
126
+ if (!steps.some(animated)) {
91
127
  out.push(err("animation-tracks-or-steps",
92
128
  `animation "${name}" animates nothing`,
93
- "At least one step needs a non-empty `tracks` object mapping a param key to keyframes.",
94
- hasSteps ? `animations.${name}.steps` : `animations.${name}.tracks`));
129
+ "At least one step needs a non-empty `tracks` object (param key → keyframes) or a non-empty `opacity` object (sub-part key keyframes).",
130
+ hasSteps ? `${base}.steps` : `${base}.tracks`));
95
131
  continue;
96
132
  }
97
133
  steps.forEach((s, i) => {
98
- if (trackful(s)) return;
134
+ if (animated(s)) return;
99
135
  // A camera-only step is legal: it holds the pose and just moves the
100
136
  // camera — an establishing shot before the motion starts. The runtime
101
137
  // emits its cue and evaluate() holds the surrounding values, so lint
@@ -103,27 +139,29 @@ export const ANIMATION_RULES = [
103
139
  if (hasSteps && s.camera != null) return;
104
140
  out.push(err("animation-tracks-or-steps",
105
141
  `animation "${name}"${hasSteps ? ` step ${i}` : ""} has no tracks`,
106
- "Every step needs a non-empty `tracks` object mapping a param key to keyframes — or, for a step that only moves the camera, a `camera` angle.",
107
- hasSteps ? `animations.${name}.steps[${i}].tracks` : `animations.${name}.tracks`));
142
+ "Every step needs a non-empty `tracks` object (param key keyframes) or an `opacity` object (sub-part key keyframes) — or, for a step that only moves the camera, a `camera` angle.",
143
+ hasSteps ? `${base}.steps[${i}].tracks` : `${base}.tracks`));
108
144
  });
109
145
  }
110
146
  return out;
111
147
  },
112
148
  },
113
149
  {
150
+ // `tracks` only — opacity keys name SUB-PARTS, not params, and are checked
151
+ // by animation-opacity-unknown-part instead.
114
152
  id: "animation-unknown-param",
115
153
  run: ({ part }) => {
116
154
  if (!isPlainObject(part?.defaults)) return [];
117
155
  const known = new Set(Object.keys(part.defaults));
118
156
  const out = [];
119
- for (const [name, a] of animEntries(part)) {
157
+ for (const { name, a, base } of animEntries(part)) {
120
158
  rawSteps(a).forEach((s, i) => {
121
159
  for (const key of Object.keys(isPlainObject(s.tracks) ? s.tracks : {})) {
122
160
  if (!known.has(key)) {
123
161
  out.push(err("animation-unknown-param",
124
162
  `animation "${name}" tracks "${key}", which is not in \`defaults\``,
125
163
  `Animations drive existing params — add "${key}" to \`defaults\` (and a control for it), or correct the key.`,
126
- `animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
164
+ `${base}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
127
165
  }
128
166
  }
129
167
  });
@@ -136,14 +174,14 @@ export const ANIMATION_RULES = [
136
174
  run: ({ part }) => {
137
175
  if (!isPlainObject(part?.defaults)) return [];
138
176
  const out = [];
139
- for (const [name, a] of animEntries(part)) {
177
+ for (const { name, a, base } of animEntries(part)) {
140
178
  rawSteps(a).forEach((s, i) => {
141
179
  for (const key of Object.keys(isPlainObject(s.tracks) ? s.tracks : {})) {
142
180
  if (key in part.defaults && typeof part.defaults[key] !== "number") {
143
181
  out.push(err("animation-param-not-numeric",
144
182
  `animation "${name}" tracks "${key}", whose default is not a number`,
145
183
  "v1 animations interpolate numeric params only — text/choice params cannot be keyframed.",
146
- `animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
184
+ `${base}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
147
185
  }
148
186
  }
149
187
  });
@@ -152,17 +190,21 @@ export const ANIMATION_RULES = [
152
190
  },
153
191
  },
154
192
  {
193
+ // Keyframe SHAPE is the same contract for both fields, so one rule owns it.
194
+ // Opacity's extra constraint (values in 0..1) is animation-opacity-range's,
195
+ // and it only walks tracks this rule has already accepted.
155
196
  id: "animation-keyframes-invalid",
156
197
  run: ({ part }) => {
157
198
  const out = [];
158
- for (const [name, a] of animEntries(part)) {
199
+ for (const { name, a, base } of animEntries(part)) {
159
200
  rawSteps(a).forEach((s, i) => {
160
- for (const [key, kf] of Object.entries(isPlainObject(s.tracks) ? s.tracks : {})) {
161
- if (!validKeyframes(kf)) {
201
+ for (const field of ["tracks", "opacity"]) {
202
+ for (const [key, kf] of Object.entries(isPlainObject(s[field]) ? s[field] : {})) {
203
+ if (validKeyframes(kf)) continue;
162
204
  out.push(err("animation-keyframes-invalid",
163
- `animation "${name}" track "${key}" has invalid keyframes`,
205
+ `animation "${name}" ${field === "opacity" ? "opacity track" : "track"} "${key}" has invalid keyframes`,
164
206
  "Keyframes are `[[t, value], …]` with finite numbers, at least two entries, `t` strictly ascending from exactly 0 to exactly 1.",
165
- `animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
207
+ `${base}${a.steps ? `.steps[${i}]` : ""}.${field}.${key}`));
166
208
  }
167
209
  }
168
210
  });
@@ -175,7 +217,7 @@ export const ANIMATION_RULES = [
175
217
  run: ({ part }) => {
176
218
  const ranges = paramRanges(part);
177
219
  const out = [];
178
- for (const [name, a] of animEntries(part)) {
220
+ for (const { name, a, base } of animEntries(part)) {
179
221
  rawSteps(a).forEach((s, i) => {
180
222
  for (const [key, kf] of Object.entries(isPlainObject(s.tracks) ? s.tracks : {})) {
181
223
  const r = ranges.get(key);
@@ -185,7 +227,7 @@ export const ANIMATION_RULES = [
185
227
  out.push(err("animation-value-out-of-range",
186
228
  `animation "${name}" track "${key}" keyframe value ${v}, outside the control's range ${r.min ?? "-∞"}..${r.max ?? "∞"}`,
187
229
  "Keyframe values are applied as-is (the engine does not clamp) — widen the control's range or move the keyframe inside it.",
188
- `animations.${name}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
230
+ `${base}${a.steps ? `.steps[${i}]` : ""}.tracks.${key}`));
189
231
  break; // one finding per track
190
232
  }
191
233
  }
@@ -195,17 +237,58 @@ export const ANIMATION_RULES = [
195
237
  return out;
196
238
  },
197
239
  },
240
+ {
241
+ id: "animation-opacity-unknown-part",
242
+ run: ({ part }) => {
243
+ const out = [];
244
+ for (const { view, name, a, base } of animEntries(part)) {
245
+ rawSteps(a).forEach((s, i) => {
246
+ for (const key of Object.keys(isPlainObject(s.opacity) ? s.opacity : {})) {
247
+ const sub = isPlainObject(part?.parts) ? part.parts[key] : undefined;
248
+ const inView = isPlainObject(sub) && Array.isArray(sub.views) && sub.views.includes(view);
249
+ if (!inView) {
250
+ out.push(err("animation-opacity-unknown-part",
251
+ `animation "${name}" fades "${key}", which is not a sub-part of view "${view}"`,
252
+ `Opacity tracks name sub-parts of the owning view — add "${view}" to \`parts.${key}.views\`, or correct the key.`,
253
+ `${base}${a.steps ? `.steps[${i}]` : ""}.opacity.${key}`));
254
+ }
255
+ }
256
+ });
257
+ }
258
+ return out;
259
+ },
260
+ },
261
+ {
262
+ id: "animation-opacity-range",
263
+ run: ({ part }) => {
264
+ const out = [];
265
+ for (const { name, a, base } of animEntries(part)) {
266
+ rawSteps(a).forEach((s, i) => {
267
+ for (const [key, kf] of Object.entries(isPlainObject(s.opacity) ? s.opacity : {})) {
268
+ if (!validKeyframes(kf)) continue; // keyframes rule already reported it
269
+ if (kf.some(([, v]) => v < 0 || v > 1)) {
270
+ out.push(err("animation-opacity-range",
271
+ `animation "${name}" opacity track "${key}" has values outside 0..1`,
272
+ "Opacity is 0 (fully hidden) to 1 (normal); it multiplies any static `display.opacity`.",
273
+ `${base}${a.steps ? `.steps[${i}]` : ""}.opacity.${key}`));
274
+ }
275
+ }
276
+ });
277
+ }
278
+ return out;
279
+ },
280
+ },
198
281
  {
199
282
  id: "animation-duration-invalid",
200
283
  run: ({ part }) => {
201
284
  const out = [];
202
- for (const [name, a] of animEntries(part)) {
285
+ for (const { name, a, base } of animEntries(part)) {
203
286
  rawSteps(a).forEach((s, i) => {
204
287
  if (!(typeof s.duration === "number" && Number.isFinite(s.duration) && s.duration > 0)) {
205
288
  out.push(err("animation-duration-invalid",
206
289
  `animation "${name}"${a.steps ? ` step ${i}` : ""} has no positive \`duration\``,
207
290
  "Every animation (or step) needs a finite `duration` in seconds, greater than 0.",
208
- `animations.${name}${a.steps ? `.steps[${i}]` : ""}.duration`));
291
+ `${base}${a.steps ? `.steps[${i}]` : ""}.duration`));
209
292
  }
210
293
  });
211
294
  }
@@ -216,7 +299,7 @@ export const ANIMATION_RULES = [
216
299
  id: "animation-loop-invalid",
217
300
  run: ({ part }) => {
218
301
  const out = [];
219
- for (const [name, a] of animEntries(part)) {
302
+ for (const { name, a, base } of animEntries(part)) {
220
303
  if (a.loop === undefined) continue;
221
304
  // Type first, like `autoplay`. The runtime fails closed (normalizeAnimation
222
305
  // reads `spec.loop === true`), so a truthy non-boolean does NOT loop — it
@@ -227,14 +310,14 @@ export const ANIMATION_RULES = [
227
310
  out.push(err("animation-loop-invalid",
228
311
  `animation "${name}" has a non-boolean \`loop\``,
229
312
  "`loop` must be `true` or `false`. Any other truthy value still loops at runtime, so it cannot be left to mean something else.",
230
- `animations.${name}.loop`));
313
+ `${base}.loop`));
231
314
  continue; // one error per field: the check below assumes a real boolean
232
315
  }
233
316
  if (a.loop && Array.isArray(a.steps) && a.steps.length > 1) {
234
317
  out.push(err("animation-loop-invalid",
235
318
  `animation "${name}" sets \`loop: true\` on a multi-step animation`,
236
319
  "Loop is for continuous single-phase motion (gears). A stepped sequence replays via the transport instead — drop `loop` or collapse to one step.",
237
- `animations.${name}.loop`));
320
+ `${base}.loop`));
238
321
  }
239
322
  }
240
323
  return out;
@@ -244,7 +327,7 @@ export const ANIMATION_RULES = [
244
327
  id: "animation-step-label-duplicate",
245
328
  run: ({ part }) => {
246
329
  const out = [];
247
- for (const [name, a] of animEntries(part)) {
330
+ for (const { name, a, base } of animEntries(part)) {
248
331
  if (!Array.isArray(a.steps)) continue;
249
332
  const seen = new Set();
250
333
  a.steps.forEach((s, i) => {
@@ -254,7 +337,7 @@ export const ANIMATION_RULES = [
254
337
  out.push(err("animation-step-label-duplicate",
255
338
  `animation "${name}" repeats the step label "${label}"`,
256
339
  "Step labels identify steps in the transport UI and the CLI's `--step <label>` — make each unique.",
257
- `animations.${name}.steps[${i}].label`));
340
+ `${base}.steps[${i}].label`));
258
341
  }
259
342
  seen.add(label);
260
343
  });
@@ -278,9 +361,9 @@ export const ANIMATION_RULES = [
278
361
  path));
279
362
  }
280
363
  };
281
- for (const [name, a] of animEntries(part)) {
282
- check(a.easing, `animations.${name}.easing`);
283
- if (Array.isArray(a.steps)) a.steps.forEach((s, i) => check(s?.easing, `animations.${name}.steps[${i}].easing`));
364
+ for (const { a, base } of animEntries(part)) {
365
+ check(a.easing, `${base}.easing`);
366
+ if (Array.isArray(a.steps)) a.steps.forEach((s, i) => check(s?.easing, `${base}.steps[${i}].easing`));
284
367
  }
285
368
  return out;
286
369
  },
@@ -290,7 +373,7 @@ export const ANIMATION_RULES = [
290
373
  run: ({ part }) => {
291
374
  const out = [];
292
375
  const badName = (v) => typeof v !== "string" || !CANONICAL_VIEWS.includes(v);
293
- for (const [name, a] of animEntries(part)) {
376
+ for (const { name, a, base } of animEntries(part)) {
294
377
  const stepCameras = Array.isArray(a.steps)
295
378
  ? a.steps.map((s, i) => [s?.camera, i]).filter(([c]) => c !== undefined && c !== null)
296
379
  : [];
@@ -298,14 +381,14 @@ export const ANIMATION_RULES = [
298
381
  out.push(err("animation-camera-invalid",
299
382
  `animation "${name}" mixes an animation-level \`camera\` with per-step cameras`,
300
383
  "One camera mechanism per animation: either the animation-level name/cue-list, or per-step names — not both.",
301
- `animations.${name}.camera`));
384
+ `${base}.camera`));
302
385
  }
303
386
  for (const [cam, i] of stepCameras) {
304
387
  if (badName(cam)) {
305
388
  out.push(err("animation-camera-invalid",
306
389
  `animation "${name}" step ${i} camera "${cam}" is not a canonical angle`,
307
390
  `Camera cues use the canonical angles: ${CANONICAL_VIEWS.join(", ")}.`,
308
- `animations.${name}.steps[${i}].camera`));
391
+ `${base}.steps[${i}].camera`));
309
392
  }
310
393
  }
311
394
  // An explicit `camera: null` is "no camera", which is how
@@ -316,7 +399,7 @@ export const ANIMATION_RULES = [
316
399
  out.push(err("animation-camera-invalid",
317
400
  `animation "${name}" camera "${a.camera}" is not a canonical angle`,
318
401
  `Camera cues use the canonical angles: ${CANONICAL_VIEWS.join(", ")}.`,
319
- `animations.${name}.camera`));
402
+ `${base}.camera`));
320
403
  }
321
404
  } else if (Array.isArray(a.camera)) {
322
405
  const cues = a.camera;
@@ -327,13 +410,13 @@ export const ANIMATION_RULES = [
327
410
  out.push(err("animation-camera-invalid",
328
411
  `animation "${name}" has an invalid camera cue list`,
329
412
  `Cues are \`[[t, angle], …]\` with t strictly ascending in 0..1 and angles from: ${CANONICAL_VIEWS.join(", ")}.`,
330
- `animations.${name}.camera`));
413
+ `${base}.camera`));
331
414
  }
332
415
  } else {
333
416
  out.push(err("animation-camera-invalid",
334
417
  `animation "${name}" \`camera\` is neither an angle name nor a cue list`,
335
418
  "Use a canonical angle string, or `[[t, angle], …]` cues.",
336
- `animations.${name}.camera`));
419
+ `${base}.camera`));
337
420
  }
338
421
  }
339
422
  return out;
@@ -342,11 +425,11 @@ export const ANIMATION_RULES = [
342
425
  {
343
426
  id: "animation-description-invalid",
344
427
  run: ({ part }) => animEntries(part)
345
- .filter(([, a]) => a.description !== undefined && typeof a.description !== "string")
346
- .map(([name]) => err("animation-description-invalid",
428
+ .filter(({ a }) => a.description !== undefined && typeof a.description !== "string")
429
+ .map(({ name, base }) => err("animation-description-invalid",
347
430
  `animation "${name}" \`description\` is not a string`,
348
431
  "The description is CommonMark shown behind the ⓘ glyph — supply a string or omit it.",
349
- `animations.${name}.description`)),
432
+ `${base}.description`)),
350
433
  },
351
434
  {
352
435
  // note tier: performance shape, not correctness. A track whose param feeds
@@ -355,7 +438,7 @@ export const ANIMATION_RULES = [
355
438
  id: "animation-track-rebuilds",
356
439
  run: ({ part, p }) => {
357
440
  const out = [];
358
- for (const [name, a] of animEntries(part)) {
441
+ for (const { view, name, a, base } of animEntries(part)) {
359
442
  const steps = rawSteps(a);
360
443
  // value range per key: the min and max across every keyframe value the
361
444
  // key ever takes, over every step that tracks it — not just the first
@@ -377,14 +460,14 @@ export const ANIMATION_RULES = [
377
460
  }
378
461
  for (const [key, [v0, v1]] of valueRange) {
379
462
  if (typeof part?.defaults?.[key] !== "number") continue; // other rules own that
380
- const cls = classifyTrack(part, p, key, v0, v1);
463
+ const cls = classifyTrack(part, p, key, v0, v1, view);
381
464
  if (cls === "pose") continue;
382
465
  out.push(note("animation-track-rebuilds",
383
466
  cls === "rebuild"
384
467
  ? `animation "${name}" track "${key}" rebuilds geometry — playback is best-effort, not frame-rate`
385
468
  : `animation "${name}" track "${key}" cannot use the pose fast path (untrusted probe) — playback is best-effort`,
386
469
  "Frame-rate playback needs the param to feed only rigid placement (translate/rotate in `place()` or at the end of `build`). If that's the intent, restructure so the param never feeds a geometry op, a query, or a function selector; if geometry morphing is the intent, this is expected.",
387
- `animations.${name}`));
470
+ base));
388
471
  }
389
472
  }
390
473
  return out;
@@ -394,49 +477,51 @@ export const ANIMATION_RULES = [
394
477
  id: "animation-autoplay-invalid",
395
478
  run: ({ part }) => {
396
479
  const out = [];
397
- let first = null;
398
- for (const [name, a] of animEntries(part)) {
480
+ // Autoplay is scoped to the view that owns it: the transport bar shows one
481
+ // view's animations at a time, so two views may each auto-start their own.
482
+ const firstByView = new Map();
483
+ for (const { view, name, a, base } of animEntries(part)) {
399
484
  if (a.autoplay !== undefined && typeof a.autoplay !== "boolean") {
400
485
  out.push(err("animation-autoplay-invalid",
401
486
  `animation "${name}" \`autoplay\` is not a boolean`,
402
- "Use `autoplay: true` on the one animation that should start on its own.",
403
- `animations.${name}.autoplay`));
487
+ "Use `autoplay: true` on the one animation in each view that should start on its own.",
488
+ `${base}.autoplay`));
404
489
  continue;
405
490
  }
406
491
  if (a.autoplay !== true) continue;
407
- if (first == null) { first = name; continue; }
492
+ if (!firstByView.has(view)) { firstByView.set(view, name); continue; }
408
493
  out.push(err("animation-autoplay-invalid",
409
- `animations "${first}" and "${name}" both declare \`autoplay\``,
410
- "Only one animation can auto-start — remove `autoplay` from all but one.",
411
- `animations.${name}.autoplay`));
494
+ `animations "${firstByView.get(view)}" and "${name}" both declare \`autoplay\` in view "${view}"`,
495
+ "Only one animation per view can auto-start — remove `autoplay` from all but one.",
496
+ `${base}.autoplay`));
412
497
  }
413
498
  return out;
414
499
  },
415
500
  },
416
501
  ];
417
502
 
418
- // Classify one animated param by probing every sub-part it can show, at the
419
- // track's two endpoint values: identical trusted baseHashes at both ends →
420
- // the param only re-poses ("pose"); differing hashes → real geometry
421
- // ("rebuild"); any untrusted probe → "untrusted" (the fast path will decline
422
- // it at runtime too). Mirrors the runtime trust model in pose-probe-core.js.
423
- function classifyTrack(part, p, key, v0, v1) {
503
+ // Classify one animated param by probing every sub-part the OWNING view can
504
+ // show, at the track's two endpoint values: identical trusted baseHashes at
505
+ // both ends → the param only re-poses ("pose"); differing hashes → real
506
+ // geometry ("rebuild"); any untrusted probe → "untrusted" (the fast path will
507
+ // decline it at runtime too). Mirrors the runtime trust model in
508
+ // pose-probe-core.js. Sub-parts outside the owning view cannot be moved by this
509
+ // animation, so probing them would only manufacture false notes.
510
+ function classifyTrack(part, p, key, v0, v1, view) {
424
511
  let result = "pose";
425
- for (const view of Object.keys(isPlainObject(part?.views) ? part.views : {})) {
426
- for (const sp of Object.values(isPlainObject(part?.parts) ? part.parts : {})) {
427
- if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
428
- const probes = [];
429
- for (const v of [v0, v1]) {
430
- const pv = { ...p, [key]: v };
431
- let dv;
432
- try { dv = resolveDerived(part, pv) ?? {}; } catch { return "untrusted"; }
433
- try { if (sp.enabled && !sp.enabled(pv)) { probes.push(null); continue; } } catch { return "untrusted"; }
434
- probes.push(probeSubPartPose(sp, { view, purpose: "display", p: pv, d: dv }));
435
- }
436
- if (probes.some((x) => x && !x.trusted)) return "untrusted";
437
- const [a, b] = probes;
438
- if (a && b && a.baseHash !== b.baseHash) result = "rebuild";
512
+ for (const sp of Object.values(isPlainObject(part?.parts) ? part.parts : {})) {
513
+ if (!Array.isArray(sp?.views) || !sp.views.includes(view)) continue;
514
+ const probes = [];
515
+ for (const v of [v0, v1]) {
516
+ const pv = { ...p, [key]: v };
517
+ let dv;
518
+ try { dv = resolveDerived(part, pv) ?? {}; } catch { return "untrusted"; }
519
+ try { if (sp.enabled && !sp.enabled(pv)) { probes.push(null); continue; } } catch { return "untrusted"; }
520
+ probes.push(probeSubPartPose(sp, { view, purpose: "display", p: pv, d: dv }));
439
521
  }
522
+ if (probes.some((x) => x && !x.trusted)) return "untrusted";
523
+ const [a, b] = probes;
524
+ if (a && b && a.baseHash !== b.baseHash) result = "rebuild";
440
525
  }
441
526
  return result;
442
527
  }
@@ -32,8 +32,10 @@ import { resolveDefaultView } from "./default-view.js";
32
32
  export function makeHandle({ ready, dispose, viewer, setParams, listExportableParts, exportParts, setHostPane, animation, getView, setView, captureView }) {
33
33
  return {
34
34
  ready, dispose, setParams,
35
- // Part-declared animation playback (spec 2026-08-02): null when the part
36
- // declares no animations. { play(name?), pause(), seek(t), stop(), state() }.
35
+ // Part-declared animation playback (spec 2026-08-02): animations are
36
+ // VIEW-owned, so this is null only when NO view declares any.
37
+ // { play(name?), pause(), seek(t), stop(), state() } — play(name) resolves
38
+ // within the ACTIVE view's set, and state() reports that view.
37
39
  animation: animation ?? null,
38
40
  // Active view name (never null once mounted). See onViewChange for the push side.
39
41
  getView,
@@ -122,13 +124,20 @@ function createCleanupStack() {
122
124
  // // loop would otherwise render a hidden pane forever.
123
125
  // // Captures still work while parked (they re-allocate).
124
126
  // // setActive(true) restores it. Safe after dispose().
125
- // runtime.animation?.play("open"); // part-declared animation playback: null when the
126
- // // part declares no animations, else
127
+ // runtime.animation?.play("open"); // part-declared animation playback: animations are
128
+ // // VIEW-owned, so this is null only when NO view
129
+ // // declares any, else
127
130
  // // { play(name?), pause(), seek(t), stop(), state() }.
128
- // // play() with an unknown name warns and does nothing;
129
- // // any user/host param edit pauses playback. A part's
130
- // // `autoplay: true` animation self-starts on first show
131
- // // and each view switch until the user touches the
131
+ // // play(name) resolves within the ACTIVE view's set
132
+ // // an unknown name (including one declared by a
133
+ // // different view) warns and does nothing; state()
134
+ // // reports the view it applies to. Switching views
135
+ // // restores the outgoing animation's params, then
136
+ // // presents the incoming view's own set (empty in a
137
+ // // view that declares none). Any user/host param edit
138
+ // // pauses playback. A view's `autoplay: true` animation
139
+ // // self-starts when that view is first shown and on
140
+ // // each switch to it, until the user touches the
132
141
  // // transport — no runtime call needed for that part.
133
142
  // const off = runtime.onContextLost(() => …); // WebGL context loss, i.e. the GPU or the
134
143
  // // OS gave up — surface it rather than showing a dead
@@ -139,10 +148,16 @@ function createCleanupStack() {
139
148
  // onViewChange fires once synchronously during mount with the initial resolved
140
149
  // view (before ready), then again on every subsequent view change (user click
141
150
  // or a programmatic setView) — always the new view name.
151
+ // onParamsCommit({ changed, params }) // the user FINISHED editing a panel control (slider
152
+ // // released, box committed, checkbox ticked, preset
153
+ // // applied): `changed` lists the keys written, `params`
154
+ // // is a snapshot copy. Never fired by setParams or
155
+ // // animation playback — hosts call setParams from their
156
+ // // own undo/reset, and firing here would loop.
142
157
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
143
158
  // exactly once here — submodules take element refs and never query the document.
144
159
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
145
- export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange,
160
+ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit,
146
161
  container: legacyContainer, controls: legacyControls } = {}) {
147
162
  // --- element resolution (the only getElementById calls in the framework, save the ?pickserver client's optional #viewbar lookup) ----
148
163
  const byId = (id) => document.getElementById(id);
@@ -233,6 +248,11 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
233
248
  // cached assembly instantly if it's current, else auto-builds what's missing.
234
249
  const tabsCtl = createViewTabs(els.tabs, part, {
235
250
  onChange: (name) => {
251
+ // FIRST: the outgoing animation restores its param snapshot, so the
252
+ // incoming view composes its assembly from un-animated params. Anything
253
+ // that reads params (refreshView / updateRelevance / the loop kick) must
254
+ // run after it. autoplayKick stays LAST — it starts the new view's own.
255
+ animCtl?.viewChanged();
236
256
  pendingPosed.clear(); cutawayChrome.reset(); refreshView(); updateRelevance(); loop.kick(); animCtl?.autoplayKick();
237
257
  onViewChange?.(name);
238
258
  },
@@ -487,7 +507,9 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
487
507
  const panel = buildControls(els.controls, part.parameters, params, () => {
488
508
  animCtl?.notifyUserEdit();
489
509
  onParamChange();
490
- });
510
+ }, onParamsCommit
511
+ ? (changed) => onParamsCommit({ changed, params: { ...params } })
512
+ : undefined);
491
513
  cleanup.defer(() => panel.dispose());
492
514
  const updateRelevance = () => {
493
515
  // A throwing derive() must not break every slider drag — mount's pick
@@ -540,11 +562,14 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
540
562
  loop.kick();
541
563
  }
542
564
 
543
- // Animation transport + driver (no-op null when the part declares none).
565
+ // Animation transport + driver (null when NO view declares animations).
566
+ // Animations are view-owned: `getView` is how the driver knows which view's
567
+ // set is live, both at attach and after every `viewChanged()`.
544
568
  animCtl = attachAnimationControls(viewer, part, {
545
569
  container: els.viewer,
546
570
  applyValues: applyAnimationValues,
547
571
  getParamValues: (keys) => Object.fromEntries(keys.map((k) => [k, params[k]])),
572
+ getView: view,
548
573
  });
549
574
  if (animCtl) cleanup.defer(() => animCtl.detach());
550
575