partforge 0.49.0 → 0.51.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 +12 -6
- package/bin/cli.js +38 -11
- package/docs/AUTHORING-PARTS.md +102 -36
- package/package.json +1 -1
- package/src/framework/animation-controls.js +108 -48
- package/src/framework/animation.js +47 -18
- package/src/framework/chrome.css +68 -0
- package/src/framework/cutaway.js +14 -0
- package/src/framework/lint/rules-animations.js +177 -92
- package/src/framework/mount.js +59 -12
- package/src/framework/rail.js +19 -2
- package/src/framework/viewer.js +155 -9
- package/src/parts/hinged-box.js +43 -27
- package/src/testing/render.js +43 -7
- package/types/index.d.ts +17 -7
- package/types/part.d.ts +38 -12
- package/types/testing.d.ts +8 -0
|
@@ -1,9 +1,15 @@
|
|
|
1
|
-
// Group 5 — the `animations`
|
|
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,
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
55
|
-
if (!isPlainObject(part
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
72
|
-
|
|
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 (
|
|
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`
|
|
78
|
-
|
|
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
|
-
|
|
120
|
+
`${base}.steps`));
|
|
86
121
|
continue;
|
|
87
122
|
}
|
|
88
123
|
const steps = rawSteps(a);
|
|
89
|
-
const
|
|
90
|
-
|
|
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
|
|
94
|
-
hasSteps ?
|
|
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 (
|
|
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
|
|
107
|
-
hasSteps ?
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
199
|
+
for (const { name, a, base } of animEntries(part)) {
|
|
159
200
|
rawSteps(a).forEach((s, i) => {
|
|
160
|
-
for (const
|
|
161
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
282
|
-
check(a.easing,
|
|
283
|
-
if (Array.isArray(a.steps)) a.steps.forEach((s, i) => check(s?.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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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((
|
|
346
|
-
.map((
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
398
|
-
|
|
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
|
-
|
|
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 (
|
|
492
|
+
if (!firstByView.has(view)) { firstByView.set(view, name); continue; }
|
|
408
493
|
out.push(err("animation-autoplay-invalid",
|
|
409
|
-
`animations "${
|
|
410
|
-
"Only one animation can auto-start — remove `autoplay` from all but one.",
|
|
411
|
-
|
|
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
|
|
419
|
-
// track's two endpoint values: identical trusted baseHashes at
|
|
420
|
-
// the param only re-poses ("pose"); differing hashes → real
|
|
421
|
-
// ("rebuild"); any untrusted probe → "untrusted" (the fast path will
|
|
422
|
-
// it at runtime too). Mirrors the runtime trust model in
|
|
423
|
-
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
}
|