rig-c 2.35.1 → 2.37.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.
@@ -0,0 +1,1262 @@
1
+ /**
2
+ * The core's live tracks (issue #1336, piece (a)): several animations over
3
+ * one document, each on a track of its own, queued, replaced and mixed out the
4
+ * way spine-core 4.3.13's `AnimationState` does it, stepped one `dt` at a
5
+ * time — a player composing a character in real time: a looping base on
6
+ * track 0, a pose held at its end on track 1 and replaced over a mix, a
7
+ * one-shot on track 2 played once and mixed back out, and the player's own
8
+ * bone-local adjustment (`LocalAdjust`, issue #1336's piece (b)) after every
9
+ * track. Every pose is the raw entry's (`RawPose` in `./raw.ts`).
10
+ *
11
+ * It sits beside `openTrack` (`./track.ts`) rather than inside it because it
12
+ * is a different object: one track holds one animation and its walk, and the
13
+ * mixing below is a second state machine as long as that module's whole body.
14
+ * What the two share is the pose path past the timelines — the adjustment,
15
+ * the world, every constraint and the physics step (`steppedLocalsWorld` in
16
+ * `./animation.ts`), the slot rows and the sliders (`slotRowsFrom`), the draw
17
+ * order, the attachments and the pose's assembly (`openRawTracksPath` in
18
+ * `./raw.ts`) — one body that the single track reaches with the locals one
19
+ * animation posed and this state with the locals its mixed entries posed.
20
+ *
21
+ * ## What a step is
22
+ *
23
+ * `step(dt)` is a player's frame on spine-core: `AnimationState.update(dt)`,
24
+ * `apply`, `Skeleton.update(dt)`, `updateWorldTransform(Physics.update)`. The
25
+ * update moves every entry's track time, starts a queued entry whose delay
26
+ * has passed, moves every mix and ends the entries a finished mix leaves; the
27
+ * apply poses every track in order 0..N over the setup pose — every pose is
28
+ * posed from the setup pose, as the single track's is, so nothing but the
29
+ * entries' state survives a step — and then the adjustment, the constraints
30
+ * and the physics. `start()` is pose 0, the reset, where `reset` says
31
+ * (`RawReset`): at the setup pose with nothing applied, or with the tracks set
32
+ * so far applied at 0 (an `apply` with no `update`) and reset there.
33
+ *
34
+ * ## The mixing — spine-core's, ported and measured
35
+ *
36
+ * The state below is `AnimationState`'s, field for field and in its order of
37
+ * operations, over the core's own timelines — so that every number is the one
38
+ * the runtime computes, which is the only thing a control at tolerance 0 can
39
+ * hold. In brief:
40
+ *
41
+ * - **Track 0 at full weight poses from the setup pose** (`MixFrom.setup`,
42
+ * alpha 1): exactly the single track's pose. `CO51`'s positive control holds
43
+ * a state with only track 0 set to `openTrack` on the same animation, every
44
+ * field, bit for bit.
45
+ * - **Every other application reads its `timelineMode`**: a property no
46
+ * earlier entry of the same track and no lower track keys mixes from the
47
+ * setup pose (`SETUP`); one a lower track keys mixes from the value it left
48
+ * (`CURRENT`: a bone channel `current + (value + setup − current)·alpha`,
49
+ * and nothing at all before the timeline's first key); one an older entry of
50
+ * the same track keys mixes from the current value, and before its first key
51
+ * towards the setup value (`FIRST`). A property the entry mixing in keys too
52
+ * is HELD on the entry mixing out — applied at the hold alpha
53
+ * `alphaMix / (1 − mix)` rather than faded, so the two do not dip through
54
+ * the setup pose — and a hold whose next entry stops keying it fades by that
55
+ * entry's own mix (`timelineHoldMix`).
56
+ * - **The mix**: `mixTime / mixDuration` clamped to 1 (a mix of 0 is 1), the
57
+ * mixing-out entry applied at `alphaMix = a·(1 − mix)`; the entry mixing in
58
+ * at the mix. A rotation mixed at an alpha below 1 turns the way it first
59
+ * turned (`applyRotateTimeline`'s remembered direction); a scale mixed out
60
+ * keeps the sign of the value it mixes from.
61
+ * - **Attachments**: an entry mixing out never keeps an attachment it keys —
62
+ * it shows it for its own deform timelines and the slot goes back to its
63
+ * setup attachment at the end of the apply unless an entry applied at full
64
+ * strength keyed it (`ATTACH_SETUP` / `ATTACH_RETAIN`).
65
+ * - **The queue**: `addAnimation` with a delay at or below 0 starts the entry
66
+ * when the entry before it completes less its mix — at its duration, or for
67
+ * a looping one at the end of its current cycle (`getTrackComplete`) — plus
68
+ * the delay; `addEmptyAnimation(track, mix, 0)` after a one-shot so mixes it
69
+ * out over its last `mix` seconds. A `delay` above 0 is that many seconds
70
+ * after the entry before starts.
71
+ * - **Events** fire from each track's current entry as the single live track
72
+ * fires them (`CO47`'s rule, across the wrap included). ⚠️ **An entry mixing
73
+ * out fires none** — not "until the mix passes half way": `eventThreshold`
74
+ * defaults to 0 and an entry's events are applied only while `mix <
75
+ * eventThreshold`, which no mix is. `complete` is not carried (issue #1276).
76
+ * - **Physics** steps on top of the mixed locals, as on one track.
77
+ *
78
+ * `CO51` holds all of it to spine-core 4.3.13 at tolerance 0 — every bone's
79
+ * world transform, every drawn attachment's world vertices, every slot's
80
+ * colour and attachment, the draw order and the events — over scripted calls
81
+ * and seeded steps on the tree's rows, and reads each rejected reading red
82
+ * (`TracksPlant`). The oracle's bones are brought back to the setup pose
83
+ * (`Skeleton.setupPoseBones`) before every `apply`, as `CO50`'s are, and
84
+ * measured that reset is needed for one thing only: with no adjustment,
85
+ * spine-core with no reset at all agrees on every walk — `AnimationState`
86
+ * writes back every property it keyed, through mixes, holds, empties and the
87
+ * queue — while the player's write on a channel no entry keys carries and
88
+ * compounds without it. The state carries nothing from one step to the next
89
+ * but its entries, so a write lasts one pose, as on the single track.
90
+ *
91
+ * ## What is left out, by name
92
+ *
93
+ * An animation is refused when it is set or queued if it keys what the state
94
+ * does not mix: a constraint timeline (ik, transform, path, physics, slider),
95
+ * a sequence timeline, the draw order, or a deform of a path attachment (a
96
+ * path's deformed curve reaches the constraints, which this state poses from
97
+ * their records as they stand). Additive entries, `timeScale`, an entry's own
98
+ * `alpha` and the listeners are not offered.
99
+ *
100
+ * ⛔ **A state refused part-way stays refused**, as a live track does: a step
101
+ * is checked before anything moves, and a refusal past that — a construct the
102
+ * core leaves out, a non-finite vertex or write, an error the caller's own
103
+ * `adjust` throws — comes after the entries and the physics have moved, so
104
+ * every later call refuses naming the call it was refused at.
105
+ */
106
+ import type { ModelBone, ModelSlot, ModelVertices } from '../model.ts';
107
+ import { activeBones, CoreInputError, rawNumber, readColour, shownAttachment, type CompiledDocument, type CoreShown } from './index.ts';
108
+ import { channelAt, keyIndexAt, SLOT_COLOUR_TIMELINES, slotRowsFrom, steppedLocalsWorld, type BoneTimelineKind, type CoreKey, type LocalAdjust, type SlotBase, type SlotColourKind, type TimelinePlant } from './animation.ts';
109
+ import { deformAt, setupArray, type CoreAttachmentTimeline } from './deform.ts';
110
+ import { eventsFired, type CoreEventKey, type CoreEventRow } from './events.ts';
111
+ import { openRawTracksPath, type RawPose, type RawReset, type RawTracksPath } from './raw.ts';
112
+ import { slotTimelinesApply } from './skins.ts';
113
+
114
+ /** How the live tracks are opened: where the physics is reset (`RawReset`, the caller's, no default) and the caller's adjustment of every pose's bone locals. */
115
+ export interface TracksOptions {
116
+ reset: RawReset;
117
+ adjust?: LocalAdjust;
118
+ }
119
+
120
+ /** How `setAnimation` starts an entry: whether it loops, and over how many seconds the entry it replaces mixes out (0 cuts). */
121
+ export interface MixOptions {
122
+ loop: boolean;
123
+ mixDuration: number;
124
+ }
125
+
126
+ /** How `addAnimation` queues an entry: `MixOptions`, and its delay — at or below 0 relative to when the entry before it completes, above 0 seconds after the entry before starts. */
127
+ export interface QueueOptions extends MixOptions {
128
+ delay: number;
129
+ }
130
+
131
+ /** A track's current entry as a player reads it: what it plays (`null` the empty animation), its times and the animation's duration — what a player detects a completion from. */
132
+ export interface TrackEntryView {
133
+ animation: string | null;
134
+ loop: boolean;
135
+ trackTime: number;
136
+ /** The animation time the entry applies at now (`getAnimationTime`): the track time wrapped or held at the duration. */
137
+ animationTime: number;
138
+ duration: number;
139
+ }
140
+
141
+ /** The live tracks (`openTracks`). */
142
+ export interface LiveTracks {
143
+ /** Replaces what `track` plays; the entry it replaces mixes out over `mixDuration` seconds (0 cuts). Every queued entry on the track is dropped. */
144
+ setAnimation(track: number, animation: string, options: MixOptions): void;
145
+ /** Mixes `track` out to nothing over `mixDuration` seconds. */
146
+ setEmptyAnimation(track: number, mixDuration: number): void;
147
+ /** Queues `animation` after the last entry on `track` (`QueueOptions`); on an empty track, it is set. */
148
+ addAnimation(track: number, animation: string, options: QueueOptions): void;
149
+ /** Queues a mix out to nothing after the last entry on `track`; `delay` 0 ends the mix when that entry completes. */
150
+ addEmptyAnimation(track: number, mixDuration: number, delay: number): void;
151
+ /** Pose 0, the reset (the header's *What a step is*): once, before the first `step`. */
152
+ start(): RawPose;
153
+ /** The next pose: every entry moved by `dt`, a finite time at or above 0. */
154
+ step(dt: number): RawPose;
155
+ /** `track`'s current entry, or `null` where it plays nothing. */
156
+ current(track: number): TrackEntryView | null;
157
+ /** The running sum of the steps taken, 0 before the first. */
158
+ readonly trackTime: number;
159
+ }
160
+
161
+ /**
162
+ * The rejected readings `CO51` plants back, each on its own: the mix reversed
163
+ * (`1 − mix`), every track above 0 mixed from the setup pose rather than the
164
+ * value the tracks below left, the hold ignored (an entry mixing out faded on
165
+ * a property the entry mixing in keys too), a queued entry's delay at or
166
+ * below 0 read as "now" rather than as the entry before's completion, and an
167
+ * entry mixing out firing its events until the mix passes one half. Nothing
168
+ * but a control passes one.
169
+ */
170
+ export interface TracksPlant extends TimelinePlant {
171
+ mixReversed?: boolean;
172
+ higherTracksFromSetup?: boolean;
173
+ holdFaded?: boolean;
174
+ queueDelayNow?: boolean;
175
+ /** An entry mixing out fires its events while the mix is below one half — the reading the issue's brief stated, which the runtime's `eventThreshold` of 0 contradicts. */
176
+ eventsUntilHalfMix?: boolean;
177
+ }
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // the timelines, as the state applies them
181
+ // ---------------------------------------------------------------------------
182
+
183
+ /** `timelineMode`'s bits, as `AnimationState` spells them: where a timeline mixes from, and whether it is held. */
184
+ const CURRENT = 0;
185
+ const SETUP = 1;
186
+ const FIRST = 2;
187
+ const MODE = 3;
188
+ const HOLD = 4;
189
+
190
+ /** One timeline of an animation in the order spine-core's `Animation.timelines` holds them: the slots' (attachment and colour), the bones', the deforms, then the events. */
191
+ type MixTimeline =
192
+ | { type: 'bone'; bone: number; kind: BoneTimelineKind; keys: CoreKey[]; ids: string[]; instant: boolean }
193
+ | { type: 'colour'; slot: number; kind: SlotColourKind; keys: CoreKey[]; ids: string[]; instant: false }
194
+ | { type: 'attachment'; slot: number; keys: CoreKey[]; ids: string[]; instant: true }
195
+ | { type: 'deform'; identity: string; timeline: CoreAttachmentTimeline; ids: string[]; instant: false }
196
+ | { type: 'events'; keys: CoreEventKey[]; ids: string[]; instant: true };
197
+
198
+ /** An animation as the state applies it: its runtime duration, its timelines and every property they key (`Animation.hasTimeline`). */
199
+ interface MixAnimation {
200
+ name: string | null;
201
+ duration: number;
202
+ timelines: MixTimeline[];
203
+ ids: ReadonlySet<string>;
204
+ }
205
+
206
+ /** The empty animation (`AnimationState.emptyAnimation`): no timeline, duration 0 — one object, so setting it on an entry never applied replaces that entry. */
207
+ const EMPTY: MixAnimation = { name: null, duration: 0, timelines: [], ids: new Set() };
208
+
209
+ /** The properties a bone timeline keys, spine-core's `propertyIds` by bone name. */
210
+ const BONE_PROPERTIES: Record<BoneTimelineKind, readonly string[]> = {
211
+ rotate: ['rotate'], translate: ['x', 'y'], translatex: ['x'], translatey: ['y'], scale: ['scaleX', 'scaleY'], scalex: ['scaleX'], scaley: ['scaleY'], shear: ['shearX', 'shearY'], shearx: ['shearX'], sheary: ['shearY'], inherit: ['inherit'],
212
+ };
213
+
214
+ /** The properties a slot colour timeline keys. */
215
+ const COLOUR_PROPERTIES: Record<SlotColourKind, readonly string[]> = { rgba: ['rgb', 'alpha'], rgb: ['rgb'], alpha: ['alpha'], rgba2: ['rgb', 'alpha', 'rgb2'], rgb2: ['rgb', 'rgb2'] };
216
+
217
+ /**
218
+ * An animation compiled for the state, or refused by name where it keys what
219
+ * the state does not mix (the header's *What is left out*). The property ids
220
+ * are spine-core's by name — `x|bone`, `rgb|slot`, `deform|slot|record` — so
221
+ * two timelines key one property exactly where the runtime's do.
222
+ */
223
+ function mixAnimationOf(doc: CompiledDocument, name: string): MixAnimation {
224
+ const anim = doc.animations.find((a) => a.name === name);
225
+ if (anim === undefined) throw new CoreInputError(`animation "${name}" is not one of this document's [${doc.animations.map((a) => a.name).join(', ')}]`);
226
+ const t = anim.timelines;
227
+ const c = anim.constraints;
228
+ const left: string[] = [];
229
+ for (const [kind, n] of [['ik', c.ik.length], ['transform', c.transform.length], ['path', c.path.length], ['physics', c.physics + (c.physicsKeyed?.length ?? 0)], ['slider', c.slider.length]] as const) if (n > 0) left.push(`${kind} constraint timelines`);
230
+ if (t.attachments.some((a) => a.sequence !== null)) left.push('a sequence timeline');
231
+ if (t.drawOrder.length > 0) left.push('the draw order');
232
+ for (const a of t.attachments) {
233
+ if (a.deform === null) continue;
234
+ const g = doc.skins.find((k) => k.name === a.skin)?.attachments[a.slot]?.[a.attachment]?.geometry;
235
+ if (g?.kind === 'path') left.push(`a deform of path attachment "${a.skin}/${a.slot}/${a.attachment}"`);
236
+ }
237
+ if (left.length > 0) throw new CoreInputError(`animation "${name}" keys ${left.join(', ')}, which the live tracks do not mix — they mix bone, slot colour, attachment and deform timelines, and fire events (issue #1336)`);
238
+ const boneIndex = new Map(doc.bones.map((b, i) => [b.name, i]));
239
+ const slotIndex = new Map(doc.slots.map((s, i) => [s.name, i]));
240
+ const timelines: MixTimeline[] = [];
241
+ for (const target of t.slots) {
242
+ const slot = slotIndex.get(target.name) as number;
243
+ for (const tl of target.timelines) {
244
+ if (tl.kind === 'attachment') timelines.push({ type: 'attachment', slot, keys: tl.keys, ids: [`attachment|${target.name}`], instant: true });
245
+ else timelines.push({ type: 'colour', slot, kind: tl.kind, keys: tl.keys, ids: COLOUR_PROPERTIES[tl.kind].map((p) => `${p}|${target.name}`), instant: false });
246
+ }
247
+ }
248
+ for (const target of t.bones) {
249
+ const bone = boneIndex.get(target.name) as number;
250
+ for (const tl of target.timelines) timelines.push({ type: 'bone', bone, kind: tl.kind, keys: tl.keys, ids: BONE_PROPERTIES[tl.kind].map((p) => `${p}|${target.name}`), instant: tl.kind === 'inherit' });
251
+ }
252
+ for (const a of t.attachments) {
253
+ if (a.deform === null) continue;
254
+ const identity = `${a.skin}/${a.slot}/${a.attachment}`;
255
+ timelines.push({ type: 'deform', identity, timeline: a, ids: [`deform|${a.slot}|${identity}`], instant: false });
256
+ }
257
+ if (t.events.length > 0) timelines.push({ type: 'events', keys: t.events, ids: ['event'], instant: true });
258
+ return { name, duration: t.duration, timelines, ids: new Set(timelines.flatMap((x) => x.ids)) };
259
+ }
260
+
261
+ /**
262
+ * The property ids an animation's timelines key, in the order the state
263
+ * applies them — what `CO51` holds to spine-core's `Animation.timelines`, since
264
+ * a timeline's mode depends on which timeline of the same track saw its
265
+ * property first.
266
+ */
267
+ export function mixPropertyOrder(doc: CompiledDocument, animation: string): string[][] {
268
+ return mixAnimationOf(doc, animation).timelines.map((x) => x.ids);
269
+ }
270
+
271
+ // ---------------------------------------------------------------------------
272
+ // the entries — `TrackEntry`
273
+ // ---------------------------------------------------------------------------
274
+
275
+ interface Entry {
276
+ anim: MixAnimation;
277
+ loop: boolean;
278
+ previous: Entry | null;
279
+ next: Entry | null;
280
+ mixingFrom: Entry | null;
281
+ mixingTo: Entry | null;
282
+ delay: number;
283
+ trackTime: number;
284
+ trackLast: number;
285
+ nextTrackLast: number;
286
+ trackEnd: number;
287
+ animationLast: number;
288
+ nextAnimationLast: number;
289
+ mixTime: number;
290
+ mixDuration: number;
291
+ totalAlpha: number;
292
+ keepHold: boolean;
293
+ timelineMode: number[];
294
+ timelineHoldMix: Array<Entry | null | undefined>;
295
+ /** Each rotate timeline's remembered direction, two numbers per timeline; its length is what marks the first frame (`applyRotateTimeline`). */
296
+ timelinesRotation: number[];
297
+ }
298
+
299
+ /** A new entry (`AnimationState.trackEntry`): every field the runtime's default; the mix duration 0, `AnimationStateData`'s default mix. */
300
+ function newEntry(anim: MixAnimation, loop: boolean): Entry {
301
+ return {
302
+ anim, loop, previous: null, next: null, mixingFrom: null, mixingTo: null,
303
+ delay: 0, trackTime: 0, trackLast: -1, nextTrackLast: -1, trackEnd: Number.MAX_VALUE,
304
+ animationLast: -1, nextAnimationLast: -1, mixTime: 0, mixDuration: 0, totalAlpha: 0, keepHold: false,
305
+ timelineMode: [], timelineHoldMix: [], timelinesRotation: [],
306
+ };
307
+ }
308
+
309
+ /** `TrackEntry.getAnimationTime`: the track time wrapped by the duration (0 over a duration of 0) when looping, else held at the duration. */
310
+ function animationTimeOf(e: Entry): number {
311
+ if (!e.loop) return Math.min(e.trackTime + 0, e.anim.duration);
312
+ const d = e.anim.duration;
313
+ if (d === 0) return 0;
314
+ return (e.trackTime % d) + 0;
315
+ }
316
+
317
+ /** `TrackEntry.getTrackComplete`: when a looping entry ends its current cycle, when a held one reaches its duration, or now. */
318
+ function trackComplete(e: Entry): number {
319
+ const d = e.anim.duration;
320
+ if (d !== 0) {
321
+ if (e.loop) return d * (1 + ((e.trackTime / d) | 0));
322
+ if (e.trackTime < d) return d;
323
+ }
324
+ return e.trackTime;
325
+ }
326
+
327
+ const signum = (v: number): number => (v > 0 ? 1 : v < 0 ? -1 : 0);
328
+ const clamp01 = (v: number): number => (v < 0 ? 0 : v > 1 ? 1 : v);
329
+
330
+ // ---------------------------------------------------------------------------
331
+ // one step's skeleton — the pose the entries are applied to
332
+ // ---------------------------------------------------------------------------
333
+
334
+ /** `Slot.attachmentState` against the apply's `unkeyedState`: untouched, shown for a deform only, or kept. */
335
+ const ATTACH_NONE = 0;
336
+ const ATTACH_SETUP = 1;
337
+ const ATTACH_RETAIN = 2;
338
+
339
+ interface SlotState {
340
+ placeholder: string | null;
341
+ light: number[];
342
+ dark: number[] | null;
343
+ deform: number[] | null;
344
+ seen: string[];
345
+ attachmentState: number;
346
+ }
347
+
348
+ /** What a slot's placeholder resolves to, as the state compares attachments: the record's own key, whether it is a vertex attachment, and the record its deform timelines are keyed as (`VertexAttachment.timelineAttachment`). */
349
+ interface Resolved {
350
+ key: string | null;
351
+ vertex: boolean;
352
+ timeline: string | null;
353
+ vertices: ModelVertices | null;
354
+ }
355
+
356
+ class Pose {
357
+ readonly bones: ModelBone[];
358
+ readonly slots: SlotState[];
359
+ readonly events: CoreEventRow[] = [];
360
+ private readonly active: ReadonlySet<string>;
361
+ private readonly live: boolean[];
362
+ private readonly resolved = new Map<string, Resolved>();
363
+
364
+ constructor(
365
+ private readonly doc: CompiledDocument,
366
+ private readonly plant: TracksPlant,
367
+ ) {
368
+ this.bones = [...doc.bones];
369
+ this.active = activeBones(doc);
370
+ const colour = plant.colour ?? readColour;
371
+ const gate = plant.slotTimelines ?? slotTimelinesApply;
372
+ this.live = doc.slots.map((s) => gate(doc, s, this.active));
373
+ this.slots = doc.slots.map((slot): SlotState => {
374
+ const setupDark = slot.dark === undefined ? null : readColour(slot.dark);
375
+ return { placeholder: slot.setup, light: slot.color === undefined ? [1, 1, 1, 1] : colour(slot.color), dark: setupDark === null ? null : [setupDark[0], setupDark[1], setupDark[2]], deform: null, seen: [], attachmentState: ATTACH_NONE };
376
+ });
377
+ }
378
+
379
+ /** Bone `i`'s locals to write: the setup bone copied on its first write, as `posedBones` copies a keyed one. */
380
+ bone(i: number): ModelBone | null {
381
+ const b = this.bones[i];
382
+ if (!this.active.has(b.name)) return null;
383
+ if (b === this.doc.bones[i]) this.bones[i] = { ...b };
384
+ return this.bones[i];
385
+ }
386
+
387
+ slotLive(i: number): boolean {
388
+ return this.live[i];
389
+ }
390
+
391
+ /** What `placeholder` shows in slot `i` (`Resolved`). */
392
+ resolve(i: number, placeholder: string | null): Resolved {
393
+ if (placeholder === null) return { key: null, vertex: false, timeline: null, vertices: null };
394
+ const slot = this.doc.slots[i];
395
+ const memo = `${i}\u0000${placeholder}`;
396
+ const known = this.resolved.get(memo);
397
+ if (known !== undefined) return known;
398
+ const s = (this.plant.shown ?? shownAttachment)(this.doc, { ...slot, setup: placeholder });
399
+ let out: Resolved;
400
+ if (s === null) out = { key: null, vertex: false, timeline: null, vertices: null };
401
+ else if ('conflict' in s) out = { key: `conflict\u0000${placeholder}`, vertex: false, timeline: null, vertices: null };
402
+ else out = resolvedOf(this.doc, slot, s);
403
+ this.resolved.set(memo, out);
404
+ return out;
405
+ }
406
+
407
+ /** `SlotPose.setAttachment`: a different attachment clears the deform unless both play the same record's timelines. */
408
+ setAttachment(i: number, placeholder: string | null): void {
409
+ const s = this.slots[i];
410
+ const was = this.resolve(i, s.placeholder);
411
+ const now = this.resolve(i, placeholder);
412
+ s.placeholder = placeholder;
413
+ if (was.key === now.key) return;
414
+ if (!now.vertex || !was.vertex || now.timeline !== was.timeline) s.deform = null;
415
+ }
416
+ }
417
+
418
+ /** A shown record as the state compares it (`Resolved`): a vertex attachment plays its own record's timelines, a linked mesh that plays its source's those. */
419
+ function resolvedOf(doc: CompiledDocument, slot: ModelSlot, s: CoreShown): Resolved {
420
+ const g = s.record.geometry;
421
+ const key = `${s.skin}\u0000${s.placeholder}`;
422
+ if (g === undefined || g.kind === 'region') return { key, vertex: false, timeline: null, vertices: null };
423
+ if (g.kind === 'linkedmesh') {
424
+ const source = doc.skins.find((k) => k.name === g.skin)?.attachments[g.slot]?.[g.source]?.geometry;
425
+ const vertices = source !== undefined && source.kind !== 'region' && source.kind !== 'linkedmesh' ? source.vertices : null;
426
+ return { key, vertex: true, timeline: s.record.timelines === true ? `${g.skin}/${g.slot}/${g.source}` : `${s.skin}/${slot.name}/${s.placeholder}`, vertices };
427
+ }
428
+ return { key, vertex: true, timeline: `${s.skin}/${slot.name}/${s.placeholder}`, vertices: g.vertices };
429
+ }
430
+
431
+ // ---------------------------------------------------------------------------
432
+ // the state — `AnimationState`
433
+ // ---------------------------------------------------------------------------
434
+
435
+ class Mixer {
436
+ readonly tracks: Array<Entry | null> = [];
437
+ private animationsChanged = false;
438
+ private readonly propertyIds = new Map<string, number>();
439
+ private readonly anims = new Map<string, MixAnimation>();
440
+
441
+ constructor(
442
+ private readonly doc: CompiledDocument,
443
+ private readonly plant: TracksPlant,
444
+ ) {}
445
+
446
+ animation(name: string): MixAnimation {
447
+ let a = this.anims.get(name);
448
+ if (a === undefined) {
449
+ a = mixAnimationOf(this.doc, name);
450
+ this.anims.set(name, a);
451
+ }
452
+ return a;
453
+ }
454
+
455
+ private changed(): void {
456
+ this.animationsChanged = true;
457
+ }
458
+
459
+ private expandToIndex(index: number): Entry | null {
460
+ while (this.tracks.length <= index) this.tracks.push(null);
461
+ return this.tracks[index];
462
+ }
463
+
464
+ private clearNext(entry: Entry): void {
465
+ entry.next = null;
466
+ }
467
+
468
+ private setTrack(index: number, current: Entry): void {
469
+ const from = this.expandToIndex(index);
470
+ this.tracks[index] = current;
471
+ current.previous = null;
472
+ if (from !== null) {
473
+ from.next = null;
474
+ current.mixingFrom = from;
475
+ from.mixingTo = current;
476
+ current.mixTime = 0;
477
+ // Reset rotation for mixing out, in case the entry was mixed in.
478
+ from.timelinesRotation.length = 0;
479
+ }
480
+ this.changed();
481
+ }
482
+
483
+ setAnimation(track: number, anim: MixAnimation, loop: boolean): Entry {
484
+ let current = this.expandToIndex(track);
485
+ if (current !== null) {
486
+ if (current.nextTrackLast === -1 && current.anim === anim) {
487
+ // Don't mix from an entry that was never applied.
488
+ this.tracks[track] = current.mixingFrom;
489
+ this.changed();
490
+ this.clearNext(current);
491
+ current = current.mixingFrom;
492
+ } else this.clearNext(current);
493
+ }
494
+ const entry = newEntry(anim, loop);
495
+ this.setTrack(track, entry);
496
+ return entry;
497
+ }
498
+
499
+ addAnimation(track: number, anim: MixAnimation, loop: boolean, delay: number): Entry {
500
+ let last = this.expandToIndex(track);
501
+ if (last !== null) while (last.next !== null) last = last.next;
502
+ const entry = newEntry(anim, loop);
503
+ if (last === null) {
504
+ this.setTrack(track, entry);
505
+ if (delay < 0) delay = 0;
506
+ } else {
507
+ last.next = entry;
508
+ entry.previous = last;
509
+ if (delay <= 0) delay = Math.max(delay + this.complete(last) - entry.mixDuration, 0);
510
+ }
511
+ entry.delay = delay;
512
+ return entry;
513
+ }
514
+
515
+ /** `TrackEntry.setMixDuration(mixDuration, delay)`. */
516
+ setMixDuration(entry: Entry, mixDuration: number, delay: number): void {
517
+ entry.mixDuration = mixDuration;
518
+ if (delay <= 0) delay = entry.previous === null ? 0 : Math.max(delay + this.complete(entry.previous) - mixDuration, 0);
519
+ entry.delay = delay;
520
+ }
521
+
522
+ /** `getTrackComplete`, or 0 under the plant that reads a queued delay as "now". */
523
+ complete(e: Entry): number {
524
+ return this.plant.queueDelayNow === true ? e.trackTime : trackComplete(e);
525
+ }
526
+
527
+ /** `TrackEntry.mix()`: `mixTime / mixDuration` clamped to 1, 1 over a mix of 0 — reversed under the plant. */
528
+ mixOf(e: Entry): number {
529
+ let m: number;
530
+ if (e.mixDuration === 0) m = 1;
531
+ else {
532
+ m = e.mixTime / e.mixDuration;
533
+ if (m >= 1) m = 1;
534
+ }
535
+ return this.plant.mixReversed === true ? 1 - m : m;
536
+ }
537
+
538
+ update(delta: number): void {
539
+ const tracks = this.tracks;
540
+ for (let i = 0, n = tracks.length; i < n; i++) {
541
+ const current = tracks[i];
542
+ if (current === null) continue;
543
+ current.animationLast = current.nextAnimationLast;
544
+ current.trackLast = current.nextTrackLast;
545
+ let currentDelta = delta;
546
+ if (current.delay > 0) {
547
+ current.delay -= currentDelta;
548
+ if (current.delay > 0) continue;
549
+ currentDelta = -current.delay;
550
+ current.delay = 0;
551
+ }
552
+ let next = current.next;
553
+ if (next !== null) {
554
+ // When the next entry's delay is passed, change to the next entry, preserving leftover time.
555
+ const nextTime = current.trackLast - next.delay;
556
+ if (nextTime >= 0) {
557
+ next.delay = 0;
558
+ next.trackTime += nextTime + delta;
559
+ current.trackTime += currentDelta;
560
+ this.setTrack(i, next);
561
+ while (next.mixingFrom !== null) {
562
+ next.mixTime += delta;
563
+ next = next.mixingFrom;
564
+ }
565
+ continue;
566
+ }
567
+ } else if (current.trackLast >= current.trackEnd && current.mixingFrom === null) {
568
+ tracks[i] = null;
569
+ this.changed();
570
+ this.clearNext(current);
571
+ continue;
572
+ }
573
+ if (current.mixingFrom !== null && this.updateMixingFrom(current, delta)) {
574
+ // End mixing from entries once all have completed.
575
+ const from = current.mixingFrom;
576
+ current.mixingFrom = null;
577
+ if (from !== null) from.mixingTo = null;
578
+ this.changed();
579
+ }
580
+ current.trackTime += currentDelta;
581
+ }
582
+ }
583
+
584
+ private updateMixingFrom(to: Entry, delta: number): boolean {
585
+ const from = to.mixingFrom;
586
+ if (from === null) return true;
587
+ const finished = this.updateMixingFrom(from, delta);
588
+ from.animationLast = from.nextAnimationLast;
589
+ from.trackLast = from.nextTrackLast;
590
+ // The from entry was applied at least once and the mix is complete.
591
+ if (to.nextTrackLast !== -1 && to.mixTime >= to.mixDuration) {
592
+ // Mixing is complete for all entries before the from entry or the mix is instantaneous.
593
+ if (from.totalAlpha === 0 || to.mixDuration === 0) {
594
+ to.mixingFrom = from.mixingFrom;
595
+ if (from.mixingFrom !== null) from.mixingFrom.mixingTo = to;
596
+ if (from.totalAlpha === 0) for (let next: Entry | null = to; next.mixingTo !== null; next = next.mixingTo) next.keepHold = true;
597
+ this.changed();
598
+ }
599
+ return finished;
600
+ }
601
+ from.trackTime += delta;
602
+ to.mixTime += delta;
603
+ return false;
604
+ }
605
+
606
+ /** `AnimationState.apply` onto one step's pose, every track in order; returns track 0's current entry's animation time (0 where it applied none). */
607
+ apply(pose: Pose): number {
608
+ if (this.animationsChanged) this.computeModes();
609
+ let base = 0;
610
+ const tracks = this.tracks;
611
+ for (let i = 0, n = tracks.length; i < n; i++) {
612
+ const current = tracks[i];
613
+ if (current === null || current.delay > 0) continue;
614
+ // Apply mixing from entries first.
615
+ let alpha = 1;
616
+ if (current.mixingFrom !== null) alpha *= this.applyMixingFrom(current, pose, i);
617
+ else if (current.trackTime >= current.trackEnd && current.next === null) alpha = 0;
618
+ // Apply the current entry.
619
+ const animationLast = current.animationLast;
620
+ const animationTime = animationTimeOf(current);
621
+ const timelines = current.anim.timelines;
622
+ const count = timelines.length;
623
+ if (i === 0 && alpha === 1) {
624
+ for (const tl of timelines) {
625
+ if (tl.type === 'attachment') this.applyAttachment(pose, tl, animationTime, SETUP, true);
626
+ else this.applyTimeline(pose, tl, animationLast, animationTime, true, 1, SETUP, false);
627
+ }
628
+ } else {
629
+ const modes = current.timelineMode;
630
+ const retain = alpha >= 0;
631
+ const firstFrame = current.timelinesRotation.length !== count << 1;
632
+ if (firstFrame) current.timelinesRotation.length = count << 1;
633
+ timelines.forEach((tl, ii) => {
634
+ const from = this.fromOf(modes[ii] & MODE, i);
635
+ if (tl.type === 'bone' && tl.kind === 'rotate') this.applyRotate(pose, tl, animationTime, alpha, from, current.timelinesRotation, ii << 1, firstFrame);
636
+ else if (tl.type === 'attachment') this.applyAttachment(pose, tl, animationTime, from, retain);
637
+ else this.applyTimeline(pose, tl, animationLast, animationTime, true, alpha, from, false);
638
+ });
639
+ }
640
+ if (i === 0) base = animationTime;
641
+ current.nextAnimationLast = animationTime;
642
+ current.nextTrackLast = current.trackTime;
643
+ }
644
+ // Set slot attachments to the setup pose if they were set temporarily to apply deform timelines.
645
+ pose.slots.forEach((s, k) => {
646
+ if (s.attachmentState === ATTACH_SETUP) pose.setAttachment(k, this.doc.slots[k].setup);
647
+ });
648
+ return base;
649
+ }
650
+
651
+ /** Where a timeline mixes from — `CURRENT` read as `SETUP` above track 0 under the plant. */
652
+ private fromOf(from: number, track: number): number {
653
+ return this.plant.higherTracksFromSetup === true && track > 0 && from === CURRENT ? SETUP : from;
654
+ }
655
+
656
+ private applyMixingFrom(to: Entry, pose: Pose, track: number): number {
657
+ const from = to.mixingFrom as Entry;
658
+ const fromMix = from.mixingFrom !== null ? this.applyMixingFrom(from, pose, track) : 1;
659
+ const mix = this.mixOf(to);
660
+ const a = 1 * fromMix;
661
+ const keep = 1 - mix * 1;
662
+ const alphaMix = a * (1 - mix);
663
+ const alphaHold = keep > 0 ? alphaMix / keep : a;
664
+ const timelines = from.anim.timelines;
665
+ const count = timelines.length;
666
+ const modes = from.timelineMode;
667
+ const holdMix = from.timelineHoldMix;
668
+ const firstFrame = from.timelinesRotation.length !== count << 1;
669
+ if (firstFrame) from.timelinesRotation.length = count << 1;
670
+ const animationLast = from.animationLast;
671
+ const animationTime = animationTimeOf(from);
672
+ const fire = this.plant.eventsUntilHalfMix === true && mix < 0.5;
673
+ from.totalAlpha = 0;
674
+ timelines.forEach((tl, i) => {
675
+ const mode = modes[i];
676
+ const mixFrom = this.fromOf(mode & MODE, track);
677
+ let alpha: number;
678
+ if ((mode & HOLD) !== 0 && this.plant.holdFaded !== true) {
679
+ const h = holdMix[i];
680
+ alpha = h === null || h === undefined ? alphaHold : alphaHold * (1 - this.mixOf(h));
681
+ } else alpha = alphaMix;
682
+ from.totalAlpha += alpha;
683
+ if (tl.type === 'bone' && tl.kind === 'rotate') this.applyRotate(pose, tl, animationTime, alpha, mixFrom, from.timelinesRotation, i << 1, firstFrame);
684
+ else if (tl.type === 'attachment') this.applyAttachment(pose, tl, animationTime, mixFrom, false);
685
+ // An entry mixing out fires no event: `eventThreshold` is 0 and its events are applied only while the mix is below it.
686
+ else this.applyTimeline(pose, tl, animationLast, animationTime, fire, alpha, mixFrom, true);
687
+ });
688
+ from.nextAnimationLast = animationTime;
689
+ from.nextTrackLast = from.trackTime;
690
+ return mix;
691
+ }
692
+
693
+ /** `_animationsChanged`: every track's entries' modes, oldest first, the property owners cleared after. */
694
+ private computeModes(): void {
695
+ this.animationsChanged = false;
696
+ this.tracks.forEach((top, i) => {
697
+ if (top === null) return;
698
+ let entry: Entry | null = top;
699
+ while (entry.mixingFrom !== null) entry = entry.mixingFrom;
700
+ do {
701
+ this.computeHold(entry, i);
702
+ entry = entry.mixingTo;
703
+ } while (entry !== null);
704
+ });
705
+ this.propertyIds.clear();
706
+ }
707
+
708
+ private computeHold(entry: Entry, track: number): void {
709
+ const timelines = entry.anim.timelines;
710
+ const modes = entry.timelineMode;
711
+ modes.length = timelines.length;
712
+ const holdMix = entry.timelineHoldMix;
713
+ holdMix.length = 0;
714
+ const to = entry.mixingTo;
715
+ timelines.forEach((tl, i) => {
716
+ const from = this.propertyFrom(track, tl.ids);
717
+ let mode: number;
718
+ // Hold if the next entry will overwrite this property.
719
+ if (to === null || tl.instant || !hasAny(to.anim, tl.ids)) mode = from;
720
+ else {
721
+ mode = from | HOLD;
722
+ // Find the next entry that doesn't overwrite this property. Its mix fades out the hold, instead of it ending abruptly.
723
+ for (let next = to.mixingTo; next !== null; next = next.mixingTo) {
724
+ if (!hasAny(next.anim, tl.ids)) {
725
+ if (next.mixDuration > 0) holdMix[i] = next;
726
+ break;
727
+ }
728
+ }
729
+ }
730
+ if (entry.keepHold) mode = (mode & ~HOLD) | ((modes[i] ?? 0) & HOLD);
731
+ modes[i] = mode;
732
+ });
733
+ }
734
+
735
+ /** `AnimationState.from`: `SETUP` for a property no one keyed yet, `FIRST` for one this track keyed first, `CURRENT` for one a lower track keyed. */
736
+ private propertyFrom(track: number, ids: readonly string[]): number {
737
+ const owners = this.propertyIds;
738
+ let from = SETUP;
739
+ for (let i = 0, n = ids.length; i < n; i++) {
740
+ const owner = owners.get(ids[i]);
741
+ if (owner === undefined) owners.set(ids[i], track);
742
+ else {
743
+ if (owner !== track) {
744
+ while (++i < n) if (!owners.has(ids[i])) owners.set(ids[i], track);
745
+ return CURRENT;
746
+ }
747
+ from = FIRST;
748
+ }
749
+ }
750
+ return from;
751
+ }
752
+
753
+ // -------------------------------------------------------------------------
754
+ // the timelines' `apply`
755
+ // -------------------------------------------------------------------------
756
+
757
+ private values(keys: readonly CoreKey[], t: number): number[] | null {
758
+ const index = (this.plant.search ?? keyIndexAt)(keys, t);
759
+ if (index < 0) return null;
760
+ const channel = this.plant.channel ?? channelAt;
761
+ return keys[index].values.map((_v, c) => channel(keys, index, c, t));
762
+ }
763
+
764
+ /** `Timeline.apply` for every timeline but an attachment's and a rotation mixed below full weight. */
765
+ private applyTimeline(pose: Pose, tl: MixTimeline, lastTime: number, time: number, fire: boolean, alpha: number, from: number, out: boolean): void {
766
+ switch (tl.type) {
767
+ case 'bone': return this.applyBone(pose, tl, time, alpha, from, out);
768
+ case 'colour': return this.applyColour(pose, tl, time, alpha, from);
769
+ case 'deform': return this.applyDeform(pose, tl, time, alpha, from);
770
+ case 'events':
771
+ if (fire) pose.events.push(...eventsFired(tl.keys, lastTime, time, rawNumber, time < lastTime));
772
+ return;
773
+ case 'attachment': return;
774
+ }
775
+ }
776
+
777
+ private applyBone(pose: Pose, tl: Extract<MixTimeline, { type: 'bone' }>, time: number, alpha: number, from: number, out: boolean): void {
778
+ const bone = pose.bone(tl.bone);
779
+ if (bone === null) return;
780
+ const setup = this.doc.bones[tl.bone];
781
+ if (tl.kind === 'inherit') {
782
+ const restore = (): void => {
783
+ if (setup.inheritMode === undefined) delete bone.inheritMode;
784
+ else bone.inheritMode = setup.inheritMode;
785
+ };
786
+ if (out) {
787
+ if (from !== CURRENT) restore();
788
+ return;
789
+ }
790
+ const index = (this.plant.search ?? keyIndexAt)(tl.keys, time);
791
+ if (index < 0) {
792
+ if (from !== CURRENT) restore();
793
+ } else bone.inheritMode = tl.keys[index].mode;
794
+ return;
795
+ }
796
+ const v = this.values(tl.keys, time);
797
+ const relative = (field: 'x' | 'y' | 'rotation' | 'shearX' | 'shearY', i: number): void => {
798
+ const s = setup[field] ?? 0;
799
+ const c = bone[field] ?? 0;
800
+ bone[field] = v === null ? beforeFirstKey(from, alpha, c, s) : from === SETUP ? s + v[i] * alpha : c + (v[i] + s - c) * alpha;
801
+ };
802
+ const pair = (fx: 'x' | 'shearX', fy: 'y' | 'shearY'): void => {
803
+ const sx = setup[fx] ?? 0;
804
+ const sy = setup[fy] ?? 0;
805
+ if (v === null) {
806
+ if (from === SETUP) {
807
+ bone[fx] = sx;
808
+ bone[fy] = sy;
809
+ } else if (from === FIRST) {
810
+ bone[fx] = (bone[fx] ?? 0) + (sx - (bone[fx] ?? 0)) * alpha;
811
+ bone[fy] = (bone[fy] ?? 0) + (sy - (bone[fy] ?? 0)) * alpha;
812
+ }
813
+ return;
814
+ }
815
+ if (from === SETUP) {
816
+ bone[fx] = sx + v[0] * alpha;
817
+ bone[fy] = sy + v[1] * alpha;
818
+ } else {
819
+ bone[fx] = (bone[fx] ?? 0) + (sx + v[0] - (bone[fx] ?? 0)) * alpha;
820
+ bone[fy] = (bone[fy] ?? 0) + (sy + v[1] - (bone[fy] ?? 0)) * alpha;
821
+ }
822
+ };
823
+ const scale = (field: 'scaleX' | 'scaleY', i: number): void => {
824
+ const s = setup[field] ?? 1;
825
+ const c = bone[field] ?? 1;
826
+ bone[field] = v === null ? beforeFirstKey(from, alpha, c, s) : scaleValue(v[i] * s, alpha, from, out, c, s);
827
+ };
828
+ switch (tl.kind) {
829
+ case 'rotate': relative('rotation', 0); break;
830
+ case 'translate': pair('x', 'y'); break;
831
+ case 'translatex': relative('x', 0); break;
832
+ case 'translatey': relative('y', 0); break;
833
+ case 'shear': pair('shearX', 'shearY'); break;
834
+ case 'shearx': relative('shearX', 0); break;
835
+ case 'sheary': relative('shearY', 0); break;
836
+ case 'scale':
837
+ if (v === null) {
838
+ const sx = setup.scaleX ?? 1;
839
+ const sy = setup.scaleY ?? 1;
840
+ if (from === SETUP) {
841
+ bone.scaleX = sx;
842
+ bone.scaleY = sy;
843
+ } else if (from === FIRST) {
844
+ bone.scaleX = (bone.scaleX ?? 1) + (sx - (bone.scaleX ?? 1)) * alpha;
845
+ bone.scaleY = (bone.scaleY ?? 1) + (sy - (bone.scaleY ?? 1)) * alpha;
846
+ }
847
+ } else {
848
+ bone.scaleX = scaleValue(v[0] * (setup.scaleX ?? 1), alpha, from, out, bone.scaleX ?? 1, setup.scaleX ?? 1);
849
+ bone.scaleY = scaleValue(v[1] * (setup.scaleY ?? 1), alpha, from, out, bone.scaleY ?? 1, setup.scaleY ?? 1);
850
+ }
851
+ break;
852
+ case 'scalex': scale('scaleX', 0); break;
853
+ case 'scaley': scale('scaleY', 0); break;
854
+ }
855
+ }
856
+
857
+ /** `applyRotateTimeline`: a rotation mixed below full weight turns the way it first turned. */
858
+ private applyRotate(pose: Pose, tl: Extract<MixTimeline, { type: 'bone' }>, time: number, alpha: number, from: number, rotations: number[], i: number, firstFrame: boolean): void {
859
+ if (firstFrame) rotations[i] = 0;
860
+ if (alpha === 1) {
861
+ this.applyBone(pose, tl, time, 1, from, false);
862
+ return;
863
+ }
864
+ const bone = pose.bone(tl.bone);
865
+ if (bone === null) return;
866
+ const setup = this.doc.bones[tl.bone].rotation ?? 0;
867
+ const v = this.values(tl.keys, time);
868
+ let r1: number;
869
+ let r2: number;
870
+ if (v === null) {
871
+ if (from === SETUP) {
872
+ bone.rotation = setup;
873
+ return;
874
+ }
875
+ if (from === CURRENT) return;
876
+ r1 = bone.rotation ?? 0;
877
+ r2 = setup;
878
+ } else {
879
+ r1 = from === SETUP ? setup : (bone.rotation ?? 0);
880
+ r2 = setup + v[0];
881
+ }
882
+ // Mix between rotations using the direction of the shortest route on the first frame while detecting crosses.
883
+ let total: number;
884
+ let diff = r2 - r1;
885
+ diff -= Math.ceil(diff / 360 - 0.5) * 360;
886
+ if (diff === 0) total = rotations[i];
887
+ else {
888
+ let lastTotal: number;
889
+ let lastDiff: number;
890
+ if (firstFrame) {
891
+ lastTotal = 0;
892
+ lastDiff = diff;
893
+ } else {
894
+ lastTotal = rotations[i];
895
+ // A slot the runtime's array never wrote reads `undefined`, which compares as NaN.
896
+ lastDiff = rotations[i + 1] ?? Number.NaN;
897
+ }
898
+ const loops = lastTotal - (lastTotal % 360);
899
+ total = diff + loops;
900
+ const current = diff >= 0;
901
+ let dir = lastTotal >= 0;
902
+ if (Math.abs(lastDiff) <= 90 && signum(lastDiff) !== signum(diff)) {
903
+ if (Math.abs(lastTotal - loops) > 180) {
904
+ total += 360 * signum(lastTotal);
905
+ dir = current;
906
+ } else if (loops !== 0) total -= 360 * signum(lastTotal);
907
+ else dir = current;
908
+ }
909
+ if (dir !== current) total += 360 * signum(lastTotal);
910
+ rotations[i] = total;
911
+ }
912
+ rotations[i + 1] = diff;
913
+ bone.rotation = r1 + total * alpha;
914
+ }
915
+
916
+ /** `applyAttachmentTimeline`: shown for good when retained, else for the deform timelines only and set back at the end of the apply. */
917
+ private applyAttachment(pose: Pose, tl: Extract<MixTimeline, { type: 'attachment' }>, time: number, from: number, retain: boolean): void {
918
+ if (!pose.slotLive(tl.slot)) return;
919
+ const s = pose.slots[tl.slot];
920
+ if (!retain && s.attachmentState === ATTACH_RETAIN) return;
921
+ const index = (this.plant.search ?? keyIndexAt)(tl.keys, time);
922
+ let setup = index < 0;
923
+ let name: string | null = null;
924
+ if (!setup) {
925
+ name = tl.keys[index].name ?? null;
926
+ setup = !retain && name === null;
927
+ }
928
+ if (setup) {
929
+ if (from === CURRENT) return;
930
+ name = this.doc.slots[tl.slot].setup;
931
+ }
932
+ pose.setAttachment(tl.slot, name);
933
+ if (retain) s.attachmentState = ATTACH_RETAIN;
934
+ else if (!setup) s.attachmentState = ATTACH_SETUP;
935
+ }
936
+
937
+ private applyColour(pose: Pose, tl: Extract<MixTimeline, { type: 'colour' }>, time: number, alpha: number, from: number): void {
938
+ if (!pose.slotLive(tl.slot)) return;
939
+ const slot = this.doc.slots[tl.slot];
940
+ const s = pose.slots[tl.slot];
941
+ const l = s.light;
942
+ const d = s.dark;
943
+ const sl = slot.color === undefined ? [1, 1, 1, 1] : (this.plant.colour ?? readColour)(slot.color);
944
+ const sdFull = slot.dark === undefined ? null : readColour(slot.dark);
945
+ const sd = sdFull === null ? [0, 0, 0] : [sdFull[0], sdFull[1], sdFull[2]];
946
+ const clampLight = (): void => {
947
+ for (let k = 0; k < 4; k++) l[k] = clamp01(l[k]);
948
+ };
949
+ const v = this.values(tl.keys, time);
950
+ const channels = SLOT_COLOUR_TIMELINES[tl.kind].channels.length;
951
+ switch (tl.kind) {
952
+ case 'rgba': {
953
+ if (v === null) {
954
+ if (from === SETUP) for (let k = 0; k < 4; k++) l[k] = sl[k];
955
+ else if (from === FIRST) {
956
+ for (let k = 0; k < 4; k++) l[k] += (sl[k] - l[k]) * alpha;
957
+ clampLight();
958
+ }
959
+ return;
960
+ }
961
+ if (alpha === 1) for (let k = 0; k < 4; k++) l[k] = v[k];
962
+ else if (from === SETUP) for (let k = 0; k < 4; k++) l[k] = sl[k] + (v[k] - sl[k]) * alpha;
963
+ else for (let k = 0; k < 4; k++) l[k] += (v[k] - l[k]) * alpha;
964
+ clampLight();
965
+ return;
966
+ }
967
+ case 'rgb': {
968
+ if (v === null) {
969
+ if (from === SETUP) for (let k = 0; k < 3; k++) l[k] = sl[k];
970
+ else if (from === FIRST) for (let k = 0; k < 3; k++) l[k] += (sl[k] - l[k]) * alpha;
971
+ return;
972
+ }
973
+ const rgb = [v[0], v[1], v[2]];
974
+ if (alpha !== 1) for (let k = 0; k < 3; k++) rgb[k] = from === SETUP ? sl[k] + (rgb[k] - sl[k]) * alpha : l[k] + (rgb[k] - l[k]) * alpha;
975
+ for (let k = 0; k < 3; k++) l[k] = clamp01(rgb[k]);
976
+ return;
977
+ }
978
+ case 'alpha': {
979
+ if (v === null) {
980
+ if (from === SETUP) l[3] = sl[3];
981
+ else if (from === FIRST) l[3] += (sl[3] - l[3]) * alpha;
982
+ return;
983
+ }
984
+ let a = v[0];
985
+ if (alpha !== 1) a = from === SETUP ? sl[3] + (a - sl[3]) * alpha : l[3] + (a - l[3]) * alpha;
986
+ l[3] = clamp01(a);
987
+ return;
988
+ }
989
+ case 'rgba2':
990
+ case 'rgb2': {
991
+ if (d === null) return;
992
+ const withAlpha = tl.kind === 'rgba2';
993
+ const lightN = withAlpha ? 4 : 3;
994
+ if (v === null) {
995
+ if (from === SETUP) {
996
+ for (let k = 0; k < lightN; k++) l[k] = sl[k];
997
+ for (let k = 0; k < 3; k++) d[k] = sd[k];
998
+ } else if (from === FIRST) {
999
+ for (let k = 0; k < lightN; k++) l[k] += (sl[k] - l[k]) * alpha;
1000
+ if (withAlpha) clampLight();
1001
+ for (let k = 0; k < 3; k++) d[k] += (sd[k] - d[k]) * alpha;
1002
+ }
1003
+ return;
1004
+ }
1005
+ const light = v.slice(0, lightN);
1006
+ const dark = v.slice(lightN, channels);
1007
+ if (withAlpha) {
1008
+ if (alpha === 1) for (let k = 0; k < 4; k++) l[k] = light[k];
1009
+ else if (from === SETUP) {
1010
+ for (let k = 0; k < 4; k++) l[k] = sl[k] + (light[k] - sl[k]) * alpha;
1011
+ for (let k = 0; k < 3; k++) dark[k] = sd[k] + (dark[k] - sd[k]) * alpha;
1012
+ } else {
1013
+ for (let k = 0; k < 4; k++) l[k] += (light[k] - l[k]) * alpha;
1014
+ for (let k = 0; k < 3; k++) dark[k] = d[k] + (dark[k] - d[k]) * alpha;
1015
+ }
1016
+ clampLight();
1017
+ } else {
1018
+ if (alpha !== 1) {
1019
+ for (let k = 0; k < 3; k++) {
1020
+ light[k] = from === SETUP ? sl[k] + (light[k] - sl[k]) * alpha : l[k] + (light[k] - l[k]) * alpha;
1021
+ dark[k] = from === SETUP ? sd[k] + (dark[k] - sd[k]) * alpha : d[k] + (dark[k] - d[k]) * alpha;
1022
+ }
1023
+ }
1024
+ for (let k = 0; k < 3; k++) l[k] = clamp01(light[k]);
1025
+ }
1026
+ for (let k = 0; k < 3; k++) d[k] = clamp01(dark[k]);
1027
+ return;
1028
+ }
1029
+ }
1030
+ }
1031
+
1032
+ /** `DeformTimeline.apply`: every live slot showing the record the timeline keys, its deform array mixed. */
1033
+ private applyDeform(pose: Pose, tl: Extract<MixTimeline, { type: 'deform' }>, time: number, alpha: number, from: number): void {
1034
+ const keys = tl.timeline.deform;
1035
+ if (keys === null) return;
1036
+ pose.slots.forEach((s, k) => {
1037
+ if (!pose.slotLive(k)) return;
1038
+ const shown = pose.resolve(k, s.placeholder);
1039
+ if (!shown.vertex || shown.timeline !== tl.identity || shown.vertices === null) return;
1040
+ const vertices = shown.vertices;
1041
+ if (!s.seen.includes(tl.identity)) s.seen.push(tl.identity);
1042
+ const target = (this.plant.deform ?? deformAt)(vertices, keys, time);
1043
+ let mixFrom = from;
1044
+ if (s.deform === null) mixFrom = SETUP;
1045
+ if (target === null) {
1046
+ // `applyBeforeFirst`.
1047
+ if (mixFrom === SETUP) s.deform = null;
1048
+ else if (mixFrom === FIRST) {
1049
+ if (alpha === 1) {
1050
+ s.deform = null;
1051
+ return;
1052
+ }
1053
+ const current = s.deform as number[];
1054
+ if (!vertices.weighted) {
1055
+ const setup = setupArray(vertices);
1056
+ s.deform = current.map((c, i) => c + (setup[i] - c) * alpha);
1057
+ } else {
1058
+ const keep = 1 - alpha;
1059
+ s.deform = current.map((c) => c * keep);
1060
+ }
1061
+ }
1062
+ return;
1063
+ }
1064
+ // `applyToSlot`, never additive.
1065
+ if (alpha === 1) s.deform = [...target];
1066
+ else if (mixFrom === SETUP) {
1067
+ if (!vertices.weighted) {
1068
+ const setup = setupArray(vertices);
1069
+ s.deform = target.map((v, i) => setup[i] + (v - setup[i]) * alpha);
1070
+ } else s.deform = target.map((v) => v * alpha);
1071
+ } else {
1072
+ const current = s.deform as number[];
1073
+ s.deform = current.map((c, i) => c + (target[i] - c) * alpha);
1074
+ }
1075
+ });
1076
+ }
1077
+ }
1078
+
1079
+ /** `CurveTimeline1.beforeFirstKey`. */
1080
+ function beforeFirstKey(from: number, alpha: number, current: number, setup: number): number {
1081
+ if (from === SETUP) return setup;
1082
+ if (from === FIRST) return current + (setup - current) * alpha;
1083
+ return current;
1084
+ }
1085
+
1086
+ /** `CurveTimeline1.getScaleValue` past the first key, `value` the curve times the setup scale, never additive. */
1087
+ function scaleValue(value: number, alpha: number, from: number, out: boolean, current: number, setup: number): number {
1088
+ if (alpha === 1) return value;
1089
+ let base = from === SETUP ? setup : current;
1090
+ if (out) return base + (Math.abs(value) * Math.sign(base) - base) * alpha;
1091
+ base = Math.abs(base) * Math.sign(value);
1092
+ return base + (value - base) * alpha;
1093
+ }
1094
+
1095
+ function hasAny(anim: MixAnimation, ids: readonly string[]): boolean {
1096
+ for (const id of ids) if (anim.ids.has(id)) return true;
1097
+ return false;
1098
+ }
1099
+
1100
+ // ---------------------------------------------------------------------------
1101
+ // the live tracks
1102
+ // ---------------------------------------------------------------------------
1103
+
1104
+ /** A track index: a whole number at or above 0, or the call refused naming it. */
1105
+ function trackIndex(track: number, call: string): number {
1106
+ if (typeof track !== 'number' || !Number.isInteger(track) || track < 0) throw new CoreInputError(`the live tracks' ${call} names track ${String(track)}, not a whole number at or above 0`);
1107
+ return track;
1108
+ }
1109
+
1110
+ function finiteAtOrAbove0(v: number, what: string, call: string): number {
1111
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0) throw new CoreInputError(`the live tracks' ${call} ${what} is ${String(v)}, not a finite time at or above 0`);
1112
+ return v;
1113
+ }
1114
+
1115
+ function loopOf(options: MixOptions | undefined, call: string): boolean {
1116
+ if (typeof options !== 'object' || options === null || typeof options.loop !== 'boolean') throw new CoreInputError(`the live tracks' ${call} options.loop is ${JSON.stringify((options as Partial<MixOptions> | null | undefined)?.loop)}, not true or false`);
1117
+ return options.loop;
1118
+ }
1119
+
1120
+ function delayOf(v: number, call: string): number {
1121
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`the live tracks' ${call} delay is ${String(v)}, not a finite number`);
1122
+ return v;
1123
+ }
1124
+
1125
+ /**
1126
+ * The live tracks over `doc` (the header): refused by name where a walk on the
1127
+ * document would be, then animations set, queued and mixed out on any track,
1128
+ * pose 0 posed by `start()` and one pose per `step`.
1129
+ */
1130
+ export function openTracks(doc: CompiledDocument, options: TracksOptions, plant: TracksPlant = {}): LiveTracks {
1131
+ if (typeof options !== 'object' || options === null || (options.reset !== 'animation' && options.reset !== 'setup')) throw new CoreInputError(`the live tracks' options.reset is ${JSON.stringify((options as Partial<TracksOptions> | null)?.reset)}, not "animation" or "setup"`);
1132
+ if (options.adjust !== undefined && typeof options.adjust !== 'function') throw new CoreInputError(`the live tracks' options.adjust is ${typeof options.adjust}, not a function`);
1133
+ return new Tracks(doc, options.reset, options.adjust, plant, openRawTracksPath(doc, plant));
1134
+ }
1135
+
1136
+ class Tracks implements LiveTracks {
1137
+ private readonly mixer: Mixer;
1138
+ private started = false;
1139
+ private steps = 0;
1140
+ private time = 0;
1141
+ private refused: string | null = null;
1142
+
1143
+ constructor(
1144
+ private readonly doc: CompiledDocument,
1145
+ private readonly reset: RawReset,
1146
+ private readonly adjust: LocalAdjust | undefined,
1147
+ private readonly plant: TracksPlant,
1148
+ private readonly path: RawTracksPath,
1149
+ ) {
1150
+ this.mixer = new Mixer(doc, plant);
1151
+ }
1152
+
1153
+ get trackTime(): number {
1154
+ return this.time;
1155
+ }
1156
+
1157
+ private live(call: string): void {
1158
+ if (this.refused !== null) throw new CoreInputError(`the live tracks were refused at ${this.refused}, after their entries and physics had moved; open them again (${call} not made)`);
1159
+ }
1160
+
1161
+ setAnimation(track: number, animation: string, options: MixOptions): void {
1162
+ this.live('setAnimation');
1163
+ const i = trackIndex(track, 'setAnimation');
1164
+ const loop = loopOf(options, 'setAnimation');
1165
+ const mix = finiteAtOrAbove0(options.mixDuration, 'mixDuration', 'setAnimation');
1166
+ const entry = this.mixer.setAnimation(i, this.mixer.animation(animation), loop);
1167
+ entry.mixDuration = mix;
1168
+ }
1169
+
1170
+ setEmptyAnimation(track: number, mixDuration: number): void {
1171
+ this.live('setEmptyAnimation');
1172
+ const i = trackIndex(track, 'setEmptyAnimation');
1173
+ const mix = finiteAtOrAbove0(mixDuration, 'mixDuration', 'setEmptyAnimation');
1174
+ const entry = this.mixer.setAnimation(i, EMPTY, false);
1175
+ entry.mixDuration = mix;
1176
+ entry.trackEnd = mix;
1177
+ }
1178
+
1179
+ addAnimation(track: number, animation: string, options: QueueOptions): void {
1180
+ this.live('addAnimation');
1181
+ const i = trackIndex(track, 'addAnimation');
1182
+ const loop = loopOf(options, 'addAnimation');
1183
+ const mix = finiteAtOrAbove0(options.mixDuration, 'mixDuration', 'addAnimation');
1184
+ const delay = delayOf(options.delay, 'addAnimation');
1185
+ const entry = this.mixer.addAnimation(i, this.mixer.animation(animation), loop, delay);
1186
+ this.mixer.setMixDuration(entry, mix, delay);
1187
+ }
1188
+
1189
+ addEmptyAnimation(track: number, mixDuration: number, delay: number): void {
1190
+ this.live('addEmptyAnimation');
1191
+ const i = trackIndex(track, 'addEmptyAnimation');
1192
+ const mix = finiteAtOrAbove0(mixDuration, 'mixDuration', 'addEmptyAnimation');
1193
+ const d = delayOf(delay, 'addEmptyAnimation');
1194
+ const entry = this.mixer.addAnimation(i, EMPTY, false, d);
1195
+ if (d <= 0) entry.delay = Math.max(entry.delay + entry.mixDuration - mix, 0);
1196
+ entry.mixDuration = mix;
1197
+ entry.trackEnd = mix;
1198
+ }
1199
+
1200
+ current(track: number): TrackEntryView | null {
1201
+ const e = this.mixer.tracks[trackIndex(track, 'current')] ?? null;
1202
+ if (e === null) return null;
1203
+ return { animation: e.anim.name, loop: e.loop, trackTime: e.trackTime, animationTime: animationTimeOf(e), duration: e.anim.duration };
1204
+ }
1205
+
1206
+ start(): RawPose {
1207
+ this.live('start');
1208
+ if (this.started) throw new CoreInputError('the live tracks were started already; pose 0 is posed once');
1209
+ this.started = true;
1210
+ return this.guard('start', () => {
1211
+ if (this.reset === 'setup') return this.path.setupPose();
1212
+ return this.pose(0, true);
1213
+ });
1214
+ }
1215
+
1216
+ step(dt: number): RawPose {
1217
+ const n = this.steps + 1;
1218
+ this.live(`step ${n}`);
1219
+ if (!this.started) throw new CoreInputError(`the live tracks' step ${n} comes before start(), which poses pose 0`);
1220
+ if (typeof dt !== 'number' || !Number.isFinite(dt) || dt < 0) throw new CoreInputError(`the live tracks' step ${n} is ${String(dt)}, not a finite time at or above 0`);
1221
+ return this.guard(`step ${n}`, () => {
1222
+ this.time += dt;
1223
+ this.mixer.update(dt);
1224
+ const pose = this.pose(dt, false);
1225
+ this.steps = n;
1226
+ return pose;
1227
+ });
1228
+ }
1229
+
1230
+ /** `f`, the state refused for good if it throws once anything has moved. */
1231
+ private guard(call: string, f: () => RawPose): RawPose {
1232
+ try {
1233
+ return f();
1234
+ } catch (err) {
1235
+ this.refused = `${call} (${err instanceof Error ? err.message : String(err)})`;
1236
+ throw err;
1237
+ }
1238
+ }
1239
+
1240
+ /** One pose: the entries applied over the setup pose, then the raw pose path (`openRawTracksPath`). */
1241
+ private pose(dt: number, first: boolean): RawPose {
1242
+ const { doc, path } = this;
1243
+ const ctx = path.ctx;
1244
+ const before = ctx.time;
1245
+ ctx.time += dt;
1246
+ const pose = new Pose(doc, this.plant);
1247
+ const animationTime = this.mixer.apply(pose);
1248
+ const mixed = new Map(doc.slots.map((s, k) => [s.name, { deform: pose.slots[k].deform, seen: pose.slots[k].seen }]));
1249
+ return path.step(first, this.time, animationTime, {
1250
+ world: (sliders) => steppedLocalsWorld(doc, pose.bones, path.raw, sliders, { ctx, before, adjust: this.adjust }, true),
1251
+ slots: (sliders, placeholders) => slotRowsFrom(doc, path.raw, sliders, placeholders, (_slot, _live): SlotBase => {
1252
+ const s = pose.slots[doc.slots.indexOf(_slot)];
1253
+ return { placeholder: s.placeholder, light: s.light, dark: s.dark };
1254
+ }),
1255
+ drawOrder: [],
1256
+ at: 0,
1257
+ sample: null,
1258
+ mixed,
1259
+ events: () => pose.events,
1260
+ });
1261
+ }
1262
+ }