spine-rigc 1.5.3 → 1.6.2

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.
@@ -0,0 +1,830 @@
1
+ /**
2
+ * Construct 5 of the core, third and fourth cuts (issue #938, step 2e-iii,
3
+ * and issue #956, step 2e-iv, of issue #380): the physics constraint — under
4
+ * `Physics.none`, the oracle's default phase, where it applies nothing, and
5
+ * stepped, `Physics.reset` then `Physics.update` at a fixed `dt`, where it
6
+ * integrates.
7
+ *
8
+ * ## What an unstepped physics constraint contributes: nothing
9
+ *
10
+ * Measured by posing hand-written skeletons through `tools/pose_oracle.ts
11
+ * dump` (spine-core 4.3.13, `--skin all`, `--physics none`) twice — once as
12
+ * written, once with every physics constraint and every physics timeline
13
+ * taken out — and comparing the two dumps at tolerance 0:
14
+ *
15
+ * - 400 one-constraint skeletons: the constrained bone in all five inherit
16
+ * modes under a reflecting, sheared, scaled parent, with amplifier bones ten
17
+ * thousand units out along its axes; each of the five components (`x`, `y`,
18
+ * `rotate`, `scaleX`, `shearX`) at 1, between 0 and 2 or negative; `limit`,
19
+ * `fps`, `inertia`, `strength`, `damping`, `mass`, `wind`, `gravity` and
20
+ * `mix` at random, each `…Global` flag at random; half of them with
21
+ * physics timelines keying `mix`, `wind` and `reset` in an animation that
22
+ * also swings the parent — sampled at seven `grid` or `irr` times, every
23
+ * sample posed on the ONE skeleton the dump reuses: **400 of 400
24
+ * identical**.
25
+ * - 462 nine-bone rigs whose two to six constraints mix physics constraints
26
+ * with ik and world- and local-space transform constraints in random order
27
+ * (so a physics constraint sits between a world-space edit and a constraint
28
+ * that re-poses it), physics timelines keying `mix` and `inertia`, five
29
+ * `irr` samples: **462 of 462 identical**, 41,577 bone-samples.
30
+ *
31
+ * So under `Physics.none` the physics constraint neither moves its bone nor
32
+ * re-poses anything below it, whatever its parameters, its `mix` or its
33
+ * timelines, and wherever it stands in the update order: `applyConstraints`
34
+ * is given no step context and applies nothing.
35
+ *
36
+ * ## The stepped phase, measured (issue #956)
37
+ *
38
+ * Every rule below was fixed the same way: hand-written skeletons dumped by
39
+ * `tools/pose_oracle.ts dump --physics step --dt <s>` (spine-core 4.3.13) and
40
+ * by the core, compared at tolerance 0, most with amplifier bones ten
41
+ * thousand units out so a difference of 1e-10 in a matrix reads as a grid
42
+ * step. spine-core's source was not read; each rule is the reading the dump
43
+ * left exact, and each is stated with the population that fixed it and the
44
+ * readings it rejected. The figures `n of 400` below are one population
45
+ * (seed 81 of the lab): 400 rigs of six bones under one to five constraints —
46
+ * physics with every component, setting, `…Global` flag and timeline at
47
+ * random, ik and transform among them, skin-required constraints, two
48
+ * animations, `dt` among 1/60, 1/30, 1/120 and a random 0.005–0.05 — exact
49
+ * on 400 of 400 (63,982 bone-samples) under the rules as written, and the
50
+ * count is how many stayed exact with ONE rule replaced by the reading named.
51
+ * The core suite's `CK` controls hold the probes.
52
+ *
53
+ * **Defaults.** A field the record leaves out reads the parser's value, read
54
+ * off the oracle's `physics` block for a constraint stating nothing: every
55
+ * component 0, `limit` 5000, `fps` 60 (`step` = 1 / fps: 45 read 0.022222),
56
+ * `inertia` 0.5, `strength` 100, `damping` 0.85, `mass` 1 (the block holds
57
+ * `massInverse`: 3 read 0.333333), `wind`, `gravity` 0, `mix` 1, every flag
58
+ * false, `scaleY` `None`. The core writes that block from its records
59
+ * (`physicsRows`), under both phases.
60
+ *
61
+ * **The schedule** (`stepSchedule`) is the oracle's, from its header: a
62
+ * fresh state per animation, the animation applied at 0 and the skeleton
63
+ * reset; then from each sample's predecessor `p`, a step to `p + k·dt` while
64
+ * that is before `t`, then to `t`. The skeleton's clock is the SUM of the
65
+ * steps' deltas (`s − previous s`), not `s`: the constraint reads its delta
66
+ * off that clock. The setup pose under the step is the reset pose. Each step
67
+ * applies the animation from the time the walk last applied it at
68
+ * (`PhysicsStepContext.last`, −1 before the first), as a player does — which
69
+ * decides only when a `reset` key fires (*reset*, below).
70
+ *
71
+ * **Which constraints step.** A constraint on an inactive bone does not. A
72
+ * constraint with `skin: true` steps exactly when an applied skin's
73
+ * `constraints.physics` list names it — under `--skin all` every skin is
74
+ * applied at once, under `--skin <name>` the named skin alone
75
+ * (`listedBySkin`, set per skin view by `underSkin` in `./index.ts`;
76
+ * `physicsActive`). Measured on the
77
+ * commander's private-corpus finding (two rows DIFF, the core leaving such
78
+ * constraints inert): hand-written documents dumped under `--physics step`
79
+ * with `--skin all`, `--skin s1` and `--skin default` — named by no skin:
80
+ * not stepped under any; named by `s1`: stepped under `all` and `s1`, not
81
+ * under `default`; named by the default skin: stepped (under `all`, and —
82
+ * issue #932 — under `default` but not under `s1`); a skin naming the
83
+ * constrained bone (skin-required or not), another physics constraint or an
84
+ * ik constraint, but not this one: not stepped; no skins at all: not
85
+ * stepped. The reading inherited from issue #938 — `skin: true` is never
86
+ * applied under `--skin all` — was measured under `Physics.none`, where a
87
+ * physics constraint applies nothing either way; it is rejected (`CK13`
88
+ * plants it and turns the listed probe red). A
89
+ * constraint whose `mix` is 0 does nothing at all at that update — not even
90
+ * read its clock: a constraint muted from 0 to 0.3 s and then keyed to 1
91
+ * integrates the whole 0.3 s on its first live update after the pending one
92
+ * (sack-pro's `fall-in`; advancing the clock at `mix` 0 kept 394 of 400). A
93
+ * constraint never updated is pending, like a reset one: its first update
94
+ * takes the bone where it is and integrates nothing (not pending kept 399 of
95
+ * 400 and turned sack-pro red).
96
+ *
97
+ * **The components.** Each is on when it is ABOVE 0: `x`, `y`, `scaleX`, and
98
+ * the rotation when `rotate` or `shearX` is (reading any non-zero as on kept
99
+ * 279 of 400). A negative `rotate` or `shearX` still weighs the rotation: its
100
+ * weight is `(rotate + shearX)·mix` (dropping the negative kept 369 of 400).
101
+ *
102
+ * **The step** (`stepPhysics`), in the order measured. `reset` clears every
103
+ * offset, velocity and lag, zeroes what remains and sets the constraint's
104
+ * clock to now; it is also what a `reset` key does, while the animation is
105
+ * applied — before the step's delta is added. Then:
106
+ *
107
+ * - `delta` is the clock since the constraint last read it (never below 0),
108
+ * added to what remains. A pending constraint takes the bone's origin as
109
+ * the one it follows and integrates nothing; what remains is kept.
110
+ * - `x`, `y`: the offset gains `(previous origin − origin)·inertia`, held to
111
+ * `±limit·delta`. Then while at least one fixed `step` remains: `velocity
112
+ * += (wind·R − offset·strength)·massInverse·step` (`y`: `−= (gravity·R +
113
+ * offset·strength)·…`), `offset += velocity·step`, `velocity *=
114
+ * damping^(60·step)` (`damping` itself kept 268 of 400; exact at 60 fps,
115
+ * where the exponent is 1), and one step is taken off. What remains is
116
+ * carried to the next update (dropping it kept 116 of 400; integrating once
117
+ * per update at `dt` in place of `step` kept 116).
118
+ * - The offset shown is `offset − lag·(1 − remaining/step)` (at least 0),
119
+ * `lag` the change the integration just made: the pose between two fixed
120
+ * steps (showing the integrated offset kept 116 of 400). The bone's origin
121
+ * moves by it times `mix` times the component.
122
+ * - Rotation and shear follow the bone's tip: with `ca` the bone's x-axis
123
+ * angle and `(dx, dy)` how far its origin moved since the last update (each
124
+ * held to `±limit·delta`), the offset gains `atan2(dy + ty, dx + tx) − ca −
125
+ * (offset − lag·z₀)·w`, brought into (−π, π] with the runtime's π
126
+ * (3.1415927: `Math.PI` kept 369 of 400), times `inertia` — `(tx, ty)` the
127
+ * tip `length·(a, c)` the last update left, `w` the weight above, and `z₀`
128
+ * the SHOWN weight of the previous update: `1 − remaining/step` before
129
+ * this update's delta is added (after it kept 177 of 400; no lag at all
130
+ * kept 176). The spring then runs as for `x` with `−((wind·sin + gravity·
131
+ * cos)·length/R + offset·strength)·massInverse` — `sin`, `cos` of the
132
+ * shown angle, re-read after every step.
133
+ * - `scaleX` follows the tip along the bone: the offset gains `(dx·cos +
134
+ * dy·sin)·inertia / r`, `r = length·|x column|`, and springs with `(wind·cos
135
+ * − gravity·sin − offset·strength)·massInverse`. ⚠️ With no rotation on,
136
+ * `r` is `length·|x column| − lag·z₀` — a dimensionless lag taken from a
137
+ * length. It is the measured reading, not a derived one: the plain `r`
138
+ * left scale-only probes off by a relative `(inertia step)·lag/length`
139
+ * (`1 − 0.00020008` on a 50-unit bone moved one unit a step, `5e-5` at 100
140
+ * units, `8e-4` at 25), and the subtraction made them exact (the plain `r`
141
+ * kept 357 of 400; with rotation on the plain `r` is the exact one).
142
+ * - Applied last: the rotation `o = (offset − lag·z)·mix` turns both columns
143
+ * by `o·rotate`; with `shearX` on, the y column turns by `o·rotate` and
144
+ * the x column by `o·(rotate + shearX)` (the first reading turned the y
145
+ * column for the shear and read 0.049 off). The x column is scaled by `1 +
146
+ * (offset − lag·z)·mix·scaleX`. The tip `length·(a, c)` is kept for the next
147
+ * update.
148
+ * - After it the bone is a world edit, as a world-space transform constraint
149
+ * leaves one: its local values read back (`localFromWorld`) and every bone
150
+ * below it posed again — so a constraint whose components are all 0 but
151
+ * whose `mix` is not still re-derives its bone.
152
+ *
153
+ * **referenceScale.** `R` above is the skeleton's `referenceScale`. A Spine
154
+ * header stating 50 moved 30 of 50 wind-and-gravity probes, and moved none
155
+ * without them. The model document states it (`referenceScale`, issue
156
+ * #958): `readModel` refuses a document without it by name and stamps it on
157
+ * every physics record (`CorePhysicsRecord.referenceScale`), which is what
158
+ * the step reads. Until #958 the core read the parser's 100 as a constant.
159
+ *
160
+ * ## The timelines
161
+ *
162
+ * `inertia`, `strength`, `damping`, `mass`, `wind`, `gravity` and `mix`
163
+ * keys, one channel each, construct 4's key search and curves (key times and
164
+ * values float32), applied in the animation's order. Before the first key
165
+ * the setup value; after it `setup + (value − setup)` — the blend at alpha 1,
166
+ * not the value itself: the two differ by a unit in the last place, and a
167
+ * mass probe (a parent thrown 3e11 units) and a two-constraint wind-and-mix
168
+ * probe both read it (the value itself kept 400 of 400 in the population and
169
+ * turned both probes red). A `mass` key states a mass: the pose holds
170
+ * `1 / (m₀ + (value − m₀))`, `m₀ = 1 / massInverse`. A key omitting its
171
+ * value reads 0, a `mix` key 1 (`docs/AUTHORING.md` §4.4; a first key with
172
+ * no value, on each of the seven, read exact — the `mass` one NaN in both
173
+ * dumps). The timeline that
174
+ * names no constraint (`*`) writes every active constraint whose `…Global`
175
+ * flag for that value is on, and resets every active constraint.
176
+ *
177
+ * **reset.** A `reset` key resets its constraints on the step whose apply
178
+ * crosses it — a key in `(last, t]`, `last` the time the walk last applied
179
+ * the animation at, −1 before the first (`resetCrossed`) — so once, as a
180
+ * player crosses it (issue #960, measured against `Animation.apply(last,
181
+ * t)` and against `AnimationState` with one track, which agreed to the bit:
182
+ * `tools/pose_oracle.ts` §`--physics step`). Until issue #960 the oracle
183
+ * applied the animation from 0 at every step, and this walk mirrored it: a
184
+ * key reset its constraints at EVERY step from it on, holding the rig still
185
+ * (ignoring the key, or firing it once, kept 278 of 400 under that
186
+ * oracle). That
187
+ * reading is rejected as a schedule no player runs; the core suite's `SC`
188
+ * controls plant it and turn the probe red on the steps after the key.
189
+ *
190
+ * ## What is not exact, and why
191
+ *
192
+ * Over 3,000 random rigs of three shapes (all five inherit modes; ik and
193
+ * transform among them; skin-required constraints; the timeline naming no
194
+ * constraint) the step read exact on 2,986. The fourteen: three are wrong
195
+ * under `--physics none` too (a transform collapsing its bone's scale, and
196
+ * no step involved); one is a bone under an inactive parent, whose zero
197
+ * matrix both dumpers take to NaN by different routes; the other ten are
198
+ * last-bit differences upstream of the step, amplified by it — seven on a
199
+ * bone in `noScale`, `noScaleOrReflection` or `noRotationOrReflection`,
200
+ * whose world transform `./world.ts` reproduces to the grid but not to the
201
+ * bit (a probe amplified a billion units read one of those modes a few
202
+ * units in the last place on 61 of 61 samples, and the four other modes on
203
+ * none), two after `localFromWorld` re-derived a bone the step then moves
204
+ * again, and one under a constraint whose rotation weight is negative, where
205
+ * the inertia pushes away and any difference grows. Each is exact with the
206
+ * bones normal, or with the weight positive. The step itself holds no
207
+ * residual found.
208
+ *
209
+ * ## Purity
210
+ *
211
+ * As the rest of the core: nothing from the Spine runtime package, nothing
212
+ * from `src/transform.ts`, no clock, no randomness, no I/O.
213
+ */
214
+ import { channelAt, keyIndexAt, poseAnimations, sampleTime, type CoreAnimationPose, type CoreCurve, type CoreKey, type SamplePhase, type TimelinePlant } from './animation.ts';
215
+ import { RUNTIME_PI, type CoreWorld } from './world.ts';
216
+ import { previousPassSlotBones, type CoreConstraintRecord } from './constraints.ts';
217
+ import { activeBones, type CompiledDocument } from './index.ts';
218
+
219
+ /** The fields a physics constraint's record may carry after `kind`, `name`, `declaredIn` — the rig branch of `buildRigConstraint` and the motion spec's physics table (`src/compile.ts`). */
220
+ export const PHYSICS_FIELDS = [
221
+ 'bone', 'scaleY', 'x', 'y', 'rotate', 'scaleX', 'shearX', 'limit', 'fps', 'inertia', 'strength', 'damping', 'mass', 'wind', 'gravity', 'mix',
222
+ 'inertiaGlobal', 'strengthGlobal', 'dampingGlobal', 'massGlobal', 'windGlobal', 'gravityGlobal', 'mixGlobal', 'skin',
223
+ ] as const;
224
+ const PHYSICS_NUMBERS = ['x', 'y', 'rotate', 'scaleX', 'shearX', 'limit', 'fps', 'inertia', 'strength', 'damping', 'mass', 'wind', 'gravity', 'mix'] as const;
225
+ const PHYSICS_FLAGS = ['inertiaGlobal', 'strengthGlobal', 'dampingGlobal', 'massGlobal', 'windGlobal', 'gravityGlobal', 'mixGlobal', 'skin'] as const;
226
+ /** The `scaleY` modes, first letter in either case (the ik constraint's enum, which a physics constraint shares). */
227
+ const SCALE_Y_MODES = ['none', 'uniform', 'volume'] as const;
228
+
229
+ /** The five components a physics constraint drives, in the row's order. */
230
+ export const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
231
+ /** The seven values its pose holds and its timelines key, in the row's order (`mass` as the key states it; the pose holds its inverse). */
232
+ export const PHYSICS_PARAMETERS = ['inertia', 'strength', 'damping', 'mass', 'wind', 'gravity', 'mix'] as const;
233
+ export type PhysicsParameter = (typeof PHYSICS_PARAMETERS)[number];
234
+ /** The timelines a physics constraint is keyed by: the seven values and `reset`. */
235
+ export const PHYSICS_TIMELINE_KINDS = [...PHYSICS_PARAMETERS, 'reset'] as const;
236
+ export type PhysicsTimelineKind = (typeof PHYSICS_TIMELINE_KINDS)[number];
237
+ /** The model's name for the timeline that names no constraint (`EVERY_GLOBAL_PHYSICS` in `src/motion.ts`). */
238
+ export const EVERY_GLOBAL_PHYSICS = '*';
239
+
240
+ /**
241
+ * The parser's value for a field the record leaves out, measured off the
242
+ * oracle's `physics` block for a constraint stating nothing (the header's
243
+ * *Defaults*): every component 0, `limit` 5000, `fps` 60, `inertia` 0.5,
244
+ * `strength` 100, `damping` 0.85, `mass` 1, `wind` and `gravity` 0, `mix` 1.
245
+ */
246
+ export const PHYSICS_DEFAULTS = { x: 0, y: 0, rotate: 0, scaleX: 0, shearX: 0, limit: 5000, fps: 60, inertia: 0.5, strength: 100, damping: 0.85, mass: 1, wind: 0, gravity: 0, mix: 1 } as const;
247
+
248
+ /** The values a physics constraint's pose holds — what its timelines set and its step reads. */
249
+ export interface PhysicsPose {
250
+ inertia: number;
251
+ strength: number;
252
+ damping: number;
253
+ massInverse: number;
254
+ wind: number;
255
+ gravity: number;
256
+ mix: number;
257
+ }
258
+
259
+ /** A physics constraint, read field by field: its bone, the components it drives, its settings and its setup pose. */
260
+ export interface CorePhysicsRecord extends PhysicsPose {
261
+ kind: 'physics';
262
+ name: string;
263
+ bone: string;
264
+ skin: boolean;
265
+ /** An applied skin's `constraints.physics` list names it — what makes a skin-required constraint step (the header's *Which constraints step*); set per skin view by `underSkin` in `./index.ts`. */
266
+ listedBySkin: boolean;
267
+ x: number;
268
+ y: number;
269
+ rotate: number;
270
+ scaleX: number;
271
+ shearX: number;
272
+ limit: number;
273
+ /** `1 / fps`, the integrator's fixed step in seconds. */
274
+ step: number;
275
+ /** The skeleton's reference scale — the document's `referenceScale` (issue #958), which wind and gravity act over. */
276
+ referenceScale: number;
277
+ /** Each `…Global` flag, by the value it opts in to being driven by the timeline that names no constraint. */
278
+ global: Record<PhysicsParameter, boolean>;
279
+ scaleYMode: 'None' | 'Uniform' | 'Volume';
280
+ }
281
+
282
+ /**
283
+ * A physics constraint's record, read field by field — every field the writer
284
+ * can write for the kind and no other, each of its type, its bone a bone of
285
+ * the document — or `undefined` with the problems named. A field left out
286
+ * reads the parser's value (`PHYSICS_DEFAULTS`). `referenceScale` is the
287
+ * document's, read by `readModel` (issue #958), and stamped on the record.
288
+ */
289
+ export function readPhysicsRecord(raw: Record<string, unknown>, name: string, where: string, bones: ReadonlySet<string>, referenceScale: number, problems: string[]): CorePhysicsRecord | undefined {
290
+ const before = problems.length;
291
+ for (const key of Object.keys(raw)) {
292
+ if (key === 'kind' || key === 'name' || key === 'declaredIn') continue;
293
+ if (!(PHYSICS_FIELDS as readonly string[]).includes(key)) problems.push(`${where}: field "${key}" is not one this reader knows; it reads [${PHYSICS_FIELDS.join(', ')}]`);
294
+ }
295
+ if (typeof raw.bone !== 'string' || !bones.has(raw.bone)) problems.push(`${where}: bone is ${JSON.stringify(raw.bone)}, not a bone of this document`);
296
+ for (const key of PHYSICS_NUMBERS) {
297
+ const v = raw[key];
298
+ if (v !== undefined && (typeof v !== 'number' || !Number.isFinite(v))) problems.push(`${where}: ${key} is ${JSON.stringify(v)}, not a finite number`);
299
+ }
300
+ for (const key of PHYSICS_FLAGS) {
301
+ const v = raw[key];
302
+ if (v !== undefined && typeof v !== 'boolean') problems.push(`${where}: ${key} is ${JSON.stringify(v)}, not a boolean`);
303
+ }
304
+ let scaleYMode: CorePhysicsRecord['scaleYMode'] = 'None';
305
+ if (raw.scaleY !== undefined) {
306
+ const folded = typeof raw.scaleY === 'string' && raw.scaleY.length > 0 ? raw.scaleY[0].toLowerCase() + raw.scaleY.slice(1) : '';
307
+ if (!(SCALE_Y_MODES as readonly string[]).includes(folded)) problems.push(`${where}: scaleY is ${JSON.stringify(raw.scaleY)}, none of ${SCALE_Y_MODES.join(', ')}`);
308
+ else scaleYMode = folded === 'uniform' ? 'Uniform' : folded === 'volume' ? 'Volume' : 'None';
309
+ }
310
+ if (problems.length !== before) return undefined;
311
+ const n = (key: keyof typeof PHYSICS_DEFAULTS): number => (typeof raw[key] === 'number' ? (raw[key] as number) : PHYSICS_DEFAULTS[key]);
312
+ const global = {} as Record<PhysicsParameter, boolean>;
313
+ for (const p of PHYSICS_PARAMETERS) global[p] = raw[`${p}Global`] === true;
314
+ return {
315
+ kind: 'physics', name, bone: raw.bone as string, skin: raw.skin === true, listedBySkin: false,
316
+ x: n('x'), y: n('y'), rotate: n('rotate'), scaleX: n('scaleX'), shearX: n('shearX'), limit: n('limit'), step: 1 / n('fps'), referenceScale,
317
+ inertia: n('inertia'), strength: n('strength'), damping: n('damping'), massInverse: 1 / n('mass'), wind: n('wind'), gravity: n('gravity'), mix: n('mix'),
318
+ global, scaleYMode,
319
+ };
320
+ }
321
+
322
+ /** Whether a physics record steps under the skin view posed: its bone active, and not skin-required unless an applied skin lists it — measured, the header's *Which constraints step*. */
323
+ export function physicsActive(r: CorePhysicsRecord, active: ReadonlySet<string>): boolean {
324
+ return (!r.skin || r.listedBySkin) && active.has(r.bone);
325
+ }
326
+
327
+ /** One physics timeline: the constraint it names (`EVERY_GLOBAL_PHYSICS` for none), what it keys, its keys (a `reset` key's `values` empty). */
328
+ export interface CorePhysicsTimeline {
329
+ name: string;
330
+ kind: PhysicsTimelineKind;
331
+ keys: CoreKey[];
332
+ }
333
+
334
+ const isObject = (v: unknown): v is Record<string, unknown> => typeof v === 'object' && v !== null && !Array.isArray(v);
335
+
336
+ /**
337
+ * How many physics timelines one animation record's `constraints.physics`
338
+ * holds — the census's count. Their key times also set the animation's
339
+ * duration, which `./animation.ts` reads.
340
+ */
341
+ export function physicsTimelineCount(value: unknown): number {
342
+ if (!Array.isArray(value)) return 0;
343
+ let n = 0;
344
+ for (const entry of value) {
345
+ if (typeof entry === 'object' && entry !== null && Array.isArray((entry as { timelines?: unknown }).timelines)) n += (entry as { timelines: unknown[] }).timelines.length;
346
+ }
347
+ return n;
348
+ }
349
+
350
+ /**
351
+ * One animation record's physics timelines, read in the record's order —
352
+ * which is the order the runtime applies them in — each naming a declared
353
+ * physics constraint or `EVERY_GLOBAL_PHYSICS`, each kind once per name, keys
354
+ * strictly increasing in time with a finite `value` (absent: 1 for `mix`, 0
355
+ * for the rest — the parser's reading) and a `reset` key a time alone.
356
+ */
357
+ export function readPhysicsTimelines(value: unknown, label: string, declared: ReadonlySet<string>, problems: string[]): CorePhysicsTimeline[] {
358
+ const out: CorePhysicsTimeline[] = [];
359
+ if (!Array.isArray(value)) return out;
360
+ value.forEach((entry, i) => {
361
+ const at = `${label}.constraints.physics[${i}]`;
362
+ if (!isObject(entry) || typeof entry.name !== 'string') {
363
+ problems.push(`${at} names no physics constraint`);
364
+ return;
365
+ }
366
+ const name = entry.name;
367
+ if (name !== EVERY_GLOBAL_PHYSICS && !declared.has(name)) problems.push(`${at}: "${name}" is not a physics constraint of this document (nor "${EVERY_GLOBAL_PHYSICS}", the timeline naming none)`);
368
+ if (!Array.isArray(entry.timelines)) {
369
+ problems.push(`${at}: timelines is not a list`);
370
+ return;
371
+ }
372
+ const seen = new Set<string>();
373
+ entry.timelines.forEach((raw: unknown, j: number) => {
374
+ const tat = `${at}.timelines[${j}]`;
375
+ const kind = isObject(raw) ? PHYSICS_TIMELINE_KINDS.find((k) => k === raw.name) : undefined;
376
+ if (!isObject(raw) || kind === undefined) {
377
+ problems.push(`${tat}: ${JSON.stringify(isObject(raw) ? raw.name : raw)} is not a physics timeline; one keys ${PHYSICS_TIMELINE_KINDS.join(', ')}`);
378
+ return;
379
+ }
380
+ if (seen.has(kind)) problems.push(`${tat}: "${kind}" is keyed twice on "${name}"`);
381
+ seen.add(kind);
382
+ const keys: CoreKey[] = [];
383
+ const rawKeys: unknown = raw.keys;
384
+ if (!Array.isArray(rawKeys) || rawKeys.length === 0) {
385
+ problems.push(`${tat} "${kind}": keys is not a non-empty list`);
386
+ return;
387
+ }
388
+ let last = -Infinity;
389
+ rawKeys.forEach((k: unknown, q: number) => {
390
+ const kat = `${tat} "${kind}".keys[${q}]`;
391
+ if (!isObject(k)) {
392
+ problems.push(`${kat} is not an object`);
393
+ return;
394
+ }
395
+ const known = kind === 'reset' ? ['time'] : ['time', 'value', 'curve'];
396
+ for (const f of Object.keys(k)) if (!known.includes(f)) problems.push(`${kat}: field "${f}" is not one this reader knows; it reads [${known.join(', ')}]`);
397
+ const time = k.time;
398
+ if (typeof time !== 'number' || !Number.isFinite(time) || time < 0) {
399
+ problems.push(`${kat}: time is ${JSON.stringify(time)}, not a finite time at or after 0`);
400
+ return;
401
+ }
402
+ if (time <= last) problems.push(`${kat}: time ${time} is not after the key before it — the writer refuses key times that do not strictly increase`);
403
+ last = Math.max(last, time);
404
+ if (kind === 'reset') {
405
+ keys.push({ time: Math.fround(time), values: [], stated: { time, values: [] }, curve: 'stepped' });
406
+ return;
407
+ }
408
+ let v = kind === 'mix' ? 1 : 0;
409
+ if (k.value !== undefined) {
410
+ if (typeof k.value !== 'number' || !Number.isFinite(k.value)) problems.push(`${kat}: value is ${JSON.stringify(k.value)}, not a finite number`);
411
+ else v = k.value;
412
+ }
413
+ let curve: CoreCurve = 'linear';
414
+ if (k.curve !== undefined) {
415
+ if (q === rawKeys.length - 1) problems.push(`${kat}: the last key carries a curve, which eases to no key — the writer refuses it`);
416
+ else if (k.curve === 'stepped') curve = 'stepped';
417
+ else if (Array.isArray(k.curve) && k.curve.length === 4 && k.curve.every((c) => typeof c === 'number' && Number.isFinite(c))) curve = k.curve as number[];
418
+ else problems.push(`${kat}: curve is ${JSON.stringify(k.curve)}, not "stepped" nor four finite numbers`);
419
+ }
420
+ keys.push({ time: Math.fround(time), values: [Math.fround(v)], stated: { time, values: [v] }, curve });
421
+ });
422
+ out.push({ name, kind, keys });
423
+ });
424
+ });
425
+ return out;
426
+ }
427
+
428
+ /** What evaluates one channel of a key and finds the key — construct 4's unless a plant passes others. */
429
+ export interface PhysicsTimelinePlant {
430
+ channel?: (keys: readonly CoreKey[], index: number, channel: number, t: number) => number;
431
+ search?: (keys: readonly CoreKey[], t: number) => number;
432
+ }
433
+
434
+ /**
435
+ * Every physics record posed by the animation's physics timelines at `t`, in
436
+ * the timelines' order (the header's *The timelines*), and the names of the
437
+ * constraints a `reset` key resets while the animation is applied from
438
+ * `last` to `t` (`resetCrossed`). `active`
439
+ * says whether a constraint is applied at all (its skin and its bone).
440
+ */
441
+ export function posedPhysics(records: readonly CorePhysicsRecord[], timelines: readonly CorePhysicsTimeline[], t: number, last: number, active: (r: CorePhysicsRecord) => boolean, plant: PhysicsTimelinePlant = {}): { records: Map<string, CorePhysicsRecord>; reset: Set<string> } {
442
+ const search = plant.search ?? keyIndexAt;
443
+ const channel = plant.channel ?? channelAt;
444
+ const setupOf = new Map(records.map((r) => [r.name, r]));
445
+ const posed = new Map(records.map((r) => [r.name, { ...r }]));
446
+ const reset = new Set<string>();
447
+ for (const tl of timelines) {
448
+ const targets = tl.name === EVERY_GLOBAL_PHYSICS
449
+ ? [...posed.values()].filter((r) => active(r) && (tl.kind === 'reset' || r.global[tl.kind]))
450
+ : [posed.get(tl.name) as CorePhysicsRecord].filter((r) => active(r));
451
+ if (tl.kind === 'reset') {
452
+ if (resetCrossed(tl.keys, last, t)) for (const r of targets) reset.add(r.name);
453
+ continue;
454
+ }
455
+ const i = search(tl.keys, t);
456
+ for (const r of targets) {
457
+ const setup = setupOf.get(r.name) as CorePhysicsRecord;
458
+ if (tl.kind === 'mass') {
459
+ // The key states a mass; the pose holds its inverse, and the setup the blend starts from is the inverse inverted.
460
+ const from = 1 / setup.massInverse;
461
+ r.massInverse = i < 0 ? setup.massInverse : 1 / (from + (channel(tl.keys, i, 0, t) - from));
462
+ } else {
463
+ r[tl.kind] = i < 0 ? setup[tl.kind] : setup[tl.kind] + (channel(tl.keys, i, 0, t) - setup[tl.kind]);
464
+ }
465
+ }
466
+ }
467
+ return { records: posed, reset };
468
+ }
469
+
470
+ /**
471
+ * Whether a `reset` timeline resets its constraints when the animation is
472
+ * applied from `last` to `t`, as a player applies it (the header's *reset*,
473
+ * issue #960): exactly when a key lies in `(last, t]` — so a key is crossed
474
+ * once, by the step that reaches it, and `last` is −1 before the walk's
475
+ * first apply, where a key at 0 is crossed.
476
+ */
477
+ export function resetCrossed(keys: readonly CoreKey[], last: number, t: number): boolean {
478
+ return keys.some((k) => last < k.time && k.time <= t);
479
+ }
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // the step
483
+ // ---------------------------------------------------------------------------
484
+
485
+ /** One physics constraint's state between two updates (the header's *The step*). */
486
+ export interface PhysicsState {
487
+ /** The next update takes the bone where it is and integrates nothing: set by a reset and on a constraint never updated. */
488
+ pending: boolean;
489
+ lastTime: number;
490
+ remaining: number;
491
+ ux: number;
492
+ uy: number;
493
+ cx: number;
494
+ cy: number;
495
+ tx: number;
496
+ ty: number;
497
+ xOffset: number;
498
+ xLag: number;
499
+ xVelocity: number;
500
+ yOffset: number;
501
+ yLag: number;
502
+ yVelocity: number;
503
+ rotateOffset: number;
504
+ rotateLag: number;
505
+ rotateVelocity: number;
506
+ scaleOffset: number;
507
+ scaleLag: number;
508
+ scaleVelocity: number;
509
+ }
510
+
511
+ /** A constraint's state before its first update: pending, its clock at 0. */
512
+ export function freshPhysicsState(): PhysicsState {
513
+ return { pending: true, lastTime: 0, remaining: 0, ux: 0, uy: 0, cx: 0, cy: 0, tx: 0, ty: 0, xOffset: 0, xLag: 0, xVelocity: 0, yOffset: 0, yLag: 0, yVelocity: 0, rotateOffset: 0, rotateLag: 0, rotateVelocity: 0, scaleOffset: 0, scaleLag: 0, scaleVelocity: 0 };
514
+ }
515
+
516
+ /** A reset: offsets, velocities and lags cleared, nothing remaining, the clock read at `time`, pending. */
517
+ export function resetPhysicsState(s: PhysicsState, time: number): void {
518
+ s.remaining = 0;
519
+ s.lastTime = time;
520
+ s.pending = true;
521
+ s.xOffset = s.xLag = s.xVelocity = 0;
522
+ s.yOffset = s.yLag = s.yVelocity = 0;
523
+ s.rotateOffset = s.rotateLag = s.rotateVelocity = 0;
524
+ s.scaleOffset = s.scaleLag = s.scaleVelocity = 0;
525
+ }
526
+
527
+ /** The runtime's two phases the oracle steps with: `reset` resets the state first. */
528
+ export type PhysicsPhase = 'reset' | 'update';
529
+
530
+ /** What steps one constraint — `stepPhysics` unless a plant passes another. */
531
+ export type PhysicsStepper = (r: CorePhysicsRecord, w: CoreWorld, length: number, ctx: PhysicsStepContext) => boolean;
532
+
533
+ /** What a stepped pose carries into every physics constraint it applies. */
534
+ export interface PhysicsStepContext {
535
+ phase: PhysicsPhase;
536
+ /** The skeleton's clock: the sum of every step's delta so far. */
537
+ time: number;
538
+ /** Each constraint's state, by name, carried from update to update. */
539
+ states: Map<string, PhysicsState>;
540
+ /** The step itself; a control passes a planted copy. */
541
+ step?: PhysicsStepper;
542
+ /**
543
+ * The animation time the walk last applied the animation at, −1 before its
544
+ * first apply — a fresh track's `TrackEntry.animationLast`, read −1 on
545
+ * spine-core 4.3.13 (issue #960). A `reset` key fires on an apply from it
546
+ * to the step's time (`resetCrossed`).
547
+ */
548
+ last: number;
549
+ }
550
+
551
+ /** A constraint's state in `ctx`, created fresh on its first update. */
552
+ export function physicsState(ctx: PhysicsStepContext, name: string): PhysicsState {
553
+ let s = ctx.states.get(name);
554
+ if (s === undefined) {
555
+ s = freshPhysicsState();
556
+ ctx.states.set(name, s);
557
+ }
558
+ return s;
559
+ }
560
+
561
+ const PI2 = RUNTIME_PI * 2;
562
+ const INV_PI2 = 1 / PI2;
563
+
564
+ /**
565
+ * One physics constraint updated on its bone's world transform `w`
566
+ * (mutated), `length` the bone's length — the header's *The step*, every
567
+ * expression in the order measured. Returns false when its `mix` is 0: the
568
+ * constraint then does nothing at all, its clock included, and the bone is
569
+ * not re-posed.
570
+ */
571
+ export function stepPhysics(r: CorePhysicsRecord, w: CoreWorld, length: number, ctx: PhysicsStepContext): boolean {
572
+ const mix = r.mix;
573
+ if (mix === 0) return false;
574
+ const s = physicsState(ctx, r.name);
575
+ const x = r.x > 0;
576
+ const y = r.y > 0;
577
+ const rotateOrShearX = r.rotate > 0 || r.shearX > 0;
578
+ const scaleX = r.scaleX > 0;
579
+ const l = length;
580
+ if (ctx.phase === 'reset') resetPhysicsState(s, ctx.time);
581
+ const delta = Math.max(ctx.time - s.lastTime, 0);
582
+ // The interpolation weight of the previous update, before this one's time is added: the rotate and the scale-only inertia read the pose as it was shown.
583
+ const shown = Math.max(0, 1 - s.remaining / r.step);
584
+ s.remaining += delta;
585
+ s.lastTime = ctx.time;
586
+ const bx = w.worldX;
587
+ const by = w.worldY;
588
+ let z = 0;
589
+ if (s.pending) {
590
+ s.pending = false;
591
+ s.ux = bx;
592
+ s.uy = by;
593
+ } else {
594
+ let a = s.remaining;
595
+ const i = r.inertia;
596
+ const t = r.step;
597
+ const f = r.referenceScale;
598
+ let d = -1;
599
+ const qx = r.limit * delta;
600
+ const qy = qx;
601
+ if (x || y) {
602
+ if (x) {
603
+ const u = (s.ux - bx) * i;
604
+ s.xOffset += u > qx ? qx : u < -qx ? -qx : u;
605
+ s.ux = bx;
606
+ }
607
+ if (y) {
608
+ const u = (s.uy - by) * i;
609
+ s.yOffset += u > qy ? qy : u < -qy ? -qy : u;
610
+ s.uy = by;
611
+ }
612
+ if (a >= t) {
613
+ d = Math.pow(r.damping, 60 * t);
614
+ const m = r.massInverse * t;
615
+ const e = r.strength;
616
+ const wind = r.wind * f;
617
+ const g = r.gravity * f;
618
+ const xs = s.xOffset;
619
+ const ys = s.yOffset;
620
+ do {
621
+ if (x) {
622
+ s.xVelocity += (wind - s.xOffset * e) * m;
623
+ s.xOffset += s.xVelocity * t;
624
+ s.xVelocity *= d;
625
+ }
626
+ if (y) {
627
+ s.yVelocity -= (g + s.yOffset * e) * m;
628
+ s.yOffset += s.yVelocity * t;
629
+ s.yVelocity *= d;
630
+ }
631
+ a -= t;
632
+ } while (a >= t);
633
+ s.xLag = s.xOffset - xs;
634
+ s.yLag = s.yOffset - ys;
635
+ }
636
+ z = Math.max(0, 1 - a / t);
637
+ if (x) w.worldX += (s.xOffset - s.xLag * z) * mix * r.x;
638
+ if (y) w.worldY += (s.yOffset - s.yLag * z) * mix * r.y;
639
+ }
640
+ if (rotateOrShearX || scaleX) {
641
+ const ca = Math.atan2(w.c, w.a);
642
+ let c = 0;
643
+ let sn = 0;
644
+ let mr = 0;
645
+ let dx = s.cx - w.worldX;
646
+ let dy = s.cy - w.worldY;
647
+ if (dx > qx) dx = qx;
648
+ else if (dx < -qx) dx = -qx;
649
+ if (dy > qy) dy = qy;
650
+ else if (dy < -qy) dy = -qy;
651
+ if (rotateOrShearX) {
652
+ mr = (r.rotate + r.shearX) * mix;
653
+ const lagged = s.rotateLag * shown;
654
+ let rr = Math.atan2(dy + s.ty, dx + s.tx) - ca - (s.rotateOffset - lagged) * mr;
655
+ s.rotateOffset += (rr - Math.ceil(rr * INV_PI2 - 0.5) * PI2) * i;
656
+ rr = (s.rotateOffset - lagged) * mr + ca;
657
+ c = Math.cos(rr);
658
+ sn = Math.sin(rr);
659
+ if (scaleX) {
660
+ rr = l * Math.sqrt(w.a * w.a + w.c * w.c);
661
+ if (rr > 0) s.scaleOffset += ((dx * c + dy * sn) * i) / rr;
662
+ }
663
+ } else {
664
+ c = Math.cos(ca);
665
+ sn = Math.sin(ca);
666
+ const rr = l * Math.sqrt(w.a * w.a + w.c * w.c) - s.scaleLag * shown;
667
+ if (rr > 0) s.scaleOffset += ((dx * c + dy * sn) * i) / rr;
668
+ }
669
+ a = s.remaining;
670
+ if (a >= t) {
671
+ if (d === -1) d = Math.pow(r.damping, 60 * t);
672
+ const m = r.massInverse * t;
673
+ const e = r.strength;
674
+ const wind = r.wind;
675
+ const g = r.gravity;
676
+ const h = l / f;
677
+ const rs = s.rotateOffset;
678
+ const ss = s.scaleOffset;
679
+ for (;;) {
680
+ a -= t;
681
+ if (scaleX) {
682
+ s.scaleVelocity += (wind * c - g * sn - s.scaleOffset * e) * m;
683
+ s.scaleOffset += s.scaleVelocity * t;
684
+ s.scaleVelocity *= d;
685
+ }
686
+ if (rotateOrShearX) {
687
+ s.rotateVelocity -= ((wind * sn + g * c) * h + s.rotateOffset * e) * m;
688
+ s.rotateOffset += s.rotateVelocity * t;
689
+ s.rotateVelocity *= d;
690
+ if (a < t) break;
691
+ const rr = s.rotateOffset * mr + ca;
692
+ c = Math.cos(rr);
693
+ sn = Math.sin(rr);
694
+ } else if (a < t) break;
695
+ }
696
+ s.rotateLag = s.rotateOffset - rs;
697
+ s.scaleLag = s.scaleOffset - ss;
698
+ }
699
+ z = Math.max(0, 1 - a / t);
700
+ }
701
+ s.remaining = a;
702
+ }
703
+ s.cx = w.worldX;
704
+ s.cy = w.worldY;
705
+ if (rotateOrShearX) {
706
+ let o = (s.rotateOffset - s.rotateLag * z) * mix;
707
+ let sn = 0;
708
+ let c = 0;
709
+ let a = 0;
710
+ if (r.shearX > 0) {
711
+ let rr = 0;
712
+ if (r.rotate > 0) {
713
+ // The rotation's share turns the y column here, and the x column with the shear below.
714
+ rr = o * r.rotate;
715
+ sn = Math.sin(rr);
716
+ c = Math.cos(rr);
717
+ a = w.b;
718
+ w.b = c * a - sn * w.d;
719
+ w.d = sn * a + c * w.d;
720
+ }
721
+ rr += o * r.shearX;
722
+ sn = Math.sin(rr);
723
+ c = Math.cos(rr);
724
+ a = w.a;
725
+ w.a = c * a - sn * w.c;
726
+ w.c = sn * a + c * w.c;
727
+ } else {
728
+ o *= r.rotate;
729
+ sn = Math.sin(o);
730
+ c = Math.cos(o);
731
+ a = w.a;
732
+ w.a = c * a - sn * w.c;
733
+ w.c = sn * a + c * w.c;
734
+ a = w.b;
735
+ w.b = c * a - sn * w.d;
736
+ w.d = sn * a + c * w.d;
737
+ }
738
+ }
739
+ if (scaleX) {
740
+ const k = 1 + (s.scaleOffset - s.scaleLag * z) * mix * r.scaleX;
741
+ w.a *= k;
742
+ w.c *= k;
743
+ }
744
+ s.tx = l * w.a;
745
+ s.ty = l * w.c;
746
+ return true;
747
+ }
748
+
749
+ /** The oracle's `physics` block written from the records, in constraint order, rounded as the oracle rounds. */
750
+ export function physicsRows(records: readonly CorePhysicsRecord[], round: (v: number) => number | null): Array<Record<string, unknown>> {
751
+ return records.map((r) => ({
752
+ name: r.name, bone: r.bone,
753
+ x: round(r.x), y: round(r.y), rotate: round(r.rotate), scaleX: round(r.scaleX), shearX: round(r.shearX), limit: round(r.limit), step: round(r.step),
754
+ inertia: round(r.inertia), strength: round(r.strength), damping: round(r.damping), massInverse: round(r.massInverse), wind: round(r.wind), gravity: round(r.gravity), mix: round(r.mix),
755
+ inertiaGlobal: r.global.inertia, strengthGlobal: r.global.strength, dampingGlobal: r.global.damping, massGlobal: r.global.mass, windGlobal: r.global.wind, gravityGlobal: r.global.gravity, mixGlobal: r.global.mix,
756
+ skinRequired: r.skin, scaleYMode: r.scaleYMode,
757
+ }));
758
+ }
759
+
760
+ // ---------------------------------------------------------------------------
761
+ // the walk
762
+ // ---------------------------------------------------------------------------
763
+
764
+ /**
765
+ * One step's constraint records: every physics record posed by the
766
+ * animation's physics timelines at `t` (`posedPhysics`), and each constraint
767
+ * a `reset` key resets reset now — while the animation is applied, before
768
+ * the skeleton's clock moves to this step (`before` is the clock it reads;
769
+ * the header's *reset*). `./animation.ts` calls it for every step of the walk.
770
+ */
771
+ export function stepPhysicsRecords(records: readonly CoreConstraintRecord[], keyed: readonly CorePhysicsTimeline[], t: number, active: ReadonlySet<string>, ctx: PhysicsStepContext, before: number): CoreConstraintRecord[] {
772
+ const physics = records.filter((r): r is CorePhysicsRecord => r.kind === 'physics');
773
+ const posed = posedPhysics(physics, keyed, t, ctx.last, (r) => physicsActive(r, active));
774
+ ctx.last = t;
775
+ for (const name of posed.reset) resetPhysicsState(physicsState(ctx, name), before);
776
+ return records.map((r) => (r.kind === 'physics' ? (posed.records.get(r.name) as CorePhysicsRecord) : r));
777
+ }
778
+
779
+ /** A fresh step context for one animation's walk (or the setup's reset), the plant's step in place of `stepPhysics` when a control passes one. */
780
+ export function freshStepContext(step?: PhysicsStepper): PhysicsStepContext {
781
+ return { phase: 'reset', time: 0, states: new Map(), last: -1, ...(step ? { step } : {}) };
782
+ }
783
+
784
+ /**
785
+ * Why the samples' bones are left out under the step, or null: a path
786
+ * constraint whose offset reads its slot bone from the previous pose, which
787
+ * is then the previous step's — a pose the walk does not carry.
788
+ */
789
+ export function steppedPreviousPassWhy(doc: CompiledDocument): string | null {
790
+ const reads = previousPassSlotBones(doc, activeBones(doc));
791
+ return reads.length === 0 ? null : `${reads.map((r) => `path constraint "${r.constraint}" reads slot bone "${r.bone}" from the previous pose`).join('; ')} — under the step the previous pose is the previous step's, which the stepped walk does not carry`;
792
+ }
793
+
794
+ /**
795
+ * The oracle's schedule (the header's *The schedule*): for each of the `n`
796
+ * samples of an animation of duration `d` under `phase`, the times the walk
797
+ * steps to after the sample before it — from the previous sample's time `p`
798
+ * (0 before the first), `p + k·dt` for `k = 1, 2, …` while that is before
799
+ * the sample's `t`, then `t` itself unless the walk is already there. The
800
+ * walk starts at 0 with the reset, so a sample at 0 takes no step.
801
+ */
802
+ export function stepSchedule(phase: SamplePhase, d: number, n: number, dt: number): number[][] {
803
+ const out: number[][] = [];
804
+ let now = 0;
805
+ for (let i = 0; i < n; i++) {
806
+ const t = sampleTime(phase, d, i, n);
807
+ const from = now;
808
+ const steps: number[] = [];
809
+ for (let k = 1; from + k * dt < t; k++) steps.push(from + k * dt);
810
+ if (steps.length > 0) now = steps[steps.length - 1];
811
+ if (t > now) {
812
+ steps.push(t);
813
+ now = t;
814
+ }
815
+ out.push(steps);
816
+ }
817
+ return out;
818
+ }
819
+
820
+ /**
821
+ * Every animation of the document walked as the oracle's `--physics step
822
+ * --dt <dt>` walks it (the header's *The schedule*) — `poseAnimations` in
823
+ * `./animation.ts` given `dt`, which poses every block of each sample off
824
+ * the last step's pose — and the steps the schedule takes to reach each
825
+ * sample, per animation.
826
+ */
827
+ export function poseSteppedAnimations(doc: CompiledDocument, phase: SamplePhase, n: number, dt: number, plant: TimelinePlant = {}): { animations: CoreAnimationPose[]; absent: Array<[string, string]>; steps: number[][] } {
828
+ const posed = poseAnimations(doc, phase, n, plant, dt);
829
+ return { ...posed, steps: doc.animations.map((a) => stepSchedule(phase, a.timelines.duration, n, dt).map((x) => x.length)) };
830
+ }