spine-rigc 2.38.0 → 2.39.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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/core/tracks.ts +223 -39
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.38.0",
3
+ "version": "2.39.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -88,14 +88,65 @@
88
88
  * compounds without it. The state carries nothing from one step to the next
89
89
  * but its entries, so a write lasts one pose, as on the single track.
90
90
  *
91
+ * ## Alpha and additive entries (issue #1344)
92
+ *
93
+ * An entry carries spine-core's `TrackEntry.alpha` (`MixOptions.alpha`, 1 when
94
+ * absent, changed while it plays by `setAlpha`) and `TrackEntry.additive`
95
+ * (`MixOptions.additive`). 4.3.13 has no `MixBlend` on an entry — `additive` is
96
+ * a boolean fixed before the next apply — so the option carries that name and
97
+ * that type. What the runtime does with them, ported and measured (`CO52`):
98
+ *
99
+ * - **Alpha is a weight, multiplied in**: the current entry applies at
100
+ * `alpha · mix`; one mixing out at `from.alpha · fromMix · (1 − mix)`, its
101
+ * held timelines at that over `1 − mix · to.alpha`. Track 0 below alpha 1
102
+ * leaves the setup-pose shortcut and mixes as a higher track does.
103
+ * - **Events ignore alpha**: the current entry fires its events at any alpha,
104
+ * 0 included; an entry mixing out fires none (`eventThreshold` 0, above).
105
+ * - **Attachments ignore alpha**: `alphaAttachmentThreshold` is 0, so the
106
+ * current entry keeps its attachments at any alpha, 0 included;
107
+ * `mixAttachmentThreshold` is 0, so an entry mixing out keeps none. The draw
108
+ * order stays refused, so `mixDrawOrderThreshold` has nothing to act on.
109
+ * - **Additive adds what the runtime marks additive** (`Timeline.additive`):
110
+ * every bone timeline keying numbers and the deform. On a property a lower
111
+ * track or an older entry keyed, the keyed value (a rotation's, a
112
+ * translation's, a shear's) is added times alpha — `current + value · alpha`
113
+ * — a scale's offset from the setup scale is added, and a deform's offset
114
+ * from the setup vertices (weighted: the offsets themselves). From the setup
115
+ * pose it is the same as replacing. A rotation is added as it stands, with
116
+ * no shortest-angle wrap and no remembered direction (`shortestRotation` is
117
+ * `additive || …`). Colour, attachment, inherit and event timelines do not
118
+ * read `add`: under an additive entry they apply as under a replacing one.
119
+ * Track 0 at alpha 1 with no mix adds nothing — the runtime passes `add`
120
+ * false on that shortcut.
121
+ * - **An additive entry is never held**, and does not hold what it mixes in
122
+ * over: its additive timelines take their mode with no `HOLD` bit, and a
123
+ * timeline of an entry it replaces is not held for it. Mixing out, an
124
+ * additive entry adds at its faded alpha, so its offset fades to nothing.
125
+ *
126
+ * `CO52`'s oracle resets bones and slots (`setupPose()`) before every apply,
127
+ * not the bones alone as `CO51`'s does: on its blend probe a two-colour key at
128
+ * a partial alpha over track 0's `rgba` mixes the dark colour from what the
129
+ * previous frame left, which carries in a runtime whose slots are not reset —
130
+ * measured off with the bones-only reset, equal with the full one. The state
131
+ * poses every frame from the setup pose, slots included, as it always has.
132
+ *
133
+ * Each rejected reading is planted (`TracksPlant`) and read red by `CO52`:
134
+ * alpha inverted, alpha ignored, additive as replace, an additive rotation
135
+ * through the remembered direction, an additive entry's attachments as a
136
+ * plain timeline, the hold blind to the entry mixing in, the holds as if no
137
+ * entry were additive, and an alpha threshold of one half on attachments.
138
+ *
91
139
  * ## What is left out, by name
92
140
  *
93
141
  * An animation is refused when it is set or queued if it keys what the state
94
142
  * does not mix: a constraint timeline (ik, transform, path, physics, slider),
95
143
  * a sequence timeline, the draw order, or a deform of a path attachment (a
96
144
  * 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.
145
+ * their records as they stand). `timeScale`, `holdPrevious`, `reverse`,
146
+ * `shortestRotation` on a replacing entry, the thresholds as options, per-pair
147
+ * mix tables and the listeners are not offered. An additive entry is refused
148
+ * nothing more: every timeline the state accepts is defined under it (*Alpha
149
+ * and additive entries* below).
99
150
  *
100
151
  * ⛔ **A state refused part-way stays refused**, as a live track does: a step
101
152
  * is checked before anything moves, and a refusal past that — a construct the
@@ -117,10 +168,18 @@ export interface TracksOptions {
117
168
  adjust?: LocalAdjust;
118
169
  }
119
170
 
120
- /** How `setAnimation` starts an entry: whether it loops, and over how many seconds the entry it replaces mixes out (0 cuts). */
171
+ /**
172
+ * How `setAnimation` starts an entry: whether it loops, over how many seconds
173
+ * the entry it replaces mixes out (0 cuts), and — issue #1344 — how strongly
174
+ * it applies and whether it adds (the header's *Alpha and additive entries*).
175
+ */
121
176
  export interface MixOptions {
122
177
  loop: boolean;
123
178
  mixDuration: number;
179
+ /** `TrackEntry.alpha`: from 0 to 1, multiplied into every timeline's weight; 1 when absent. Changed while the entry plays with `LiveTracks.setAlpha`. */
180
+ alpha?: number;
181
+ /** `TrackEntry.additive`: the entry's bone and deform timelines add their keyed values to what the tracks below posed rather than replacing it; `false` when absent. Fixed when the entry is set or queued, as the runtime fixes it before the next apply. */
182
+ additive?: boolean;
124
183
  }
125
184
 
126
185
  /** 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. */
@@ -136,6 +195,10 @@ export interface TrackEntryView {
136
195
  /** The animation time the entry applies at now (`getAnimationTime`): the track time wrapped or held at the duration. */
137
196
  animationTime: number;
138
197
  duration: number;
198
+ /** The entry's alpha as it stands (`MixOptions.alpha`, `LiveTracks.setAlpha`). */
199
+ alpha: number;
200
+ /** Whether the entry adds (`MixOptions.additive`). */
201
+ additive: boolean;
139
202
  }
140
203
 
141
204
  /** The live tracks (`openTracks`). */
@@ -148,6 +211,8 @@ export interface LiveTracks {
148
211
  addAnimation(track: number, animation: string, options: QueueOptions): void;
149
212
  /** Queues a mix out to nothing after the last entry on `track`; `delay` 0 ends the mix when that entry completes. */
150
213
  addEmptyAnimation(track: number, mixDuration: number, delay: number): void;
214
+ /** Sets the alpha of `track`'s current entry (`TrackEntry.alpha`), from the next pose on; an entry it mixes from keeps its own. A track playing nothing is refused. */
215
+ setAlpha(track: number, alpha: number): void;
151
216
  /** Pose 0, the reset (the header's *What a step is*): once, before the first `step`. */
152
217
  start(): RawPose;
153
218
  /** The next pose: every entry moved by `dt`, a finite time at or above 0. */
@@ -174,6 +239,22 @@ export interface TracksPlant extends TimelinePlant {
174
239
  queueDelayNow?: boolean;
175
240
  /** 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
241
  eventsUntilHalfMix?: boolean;
242
+ /** `CO52`'s, issue #1344: every entry's alpha read as `1 − alpha`. */
243
+ alphaInverted?: boolean;
244
+ /** Every entry's alpha read as 1 — the option ignored. */
245
+ alphaIgnored?: boolean;
246
+ /** An additive entry applied, and its modes computed, as a replacing one. */
247
+ additiveAsReplace?: boolean;
248
+ /** An additive entry's rotations mixed through the remembered direction (`applyRotateTimeline`) as a replacing entry's are, rather than added through the timeline itself. */
249
+ additiveRotateRemembered?: boolean;
250
+ /** An additive entry's attachment timelines applied as a plain timeline (`AttachmentTimeline.apply`, no attachment state) rather than under the threshold rule (`applyAttachmentTimeline`). */
251
+ additiveAttachmentsDirect?: boolean;
252
+ /** The hold alpha computed as if the entry mixing in were at alpha 1 (`keep = 1 − mix`, not `1 − mix·to.alpha`). */
253
+ holdIgnoresEntryAlpha?: boolean;
254
+ /** The holds computed as if no entry were additive: an additive entry's own timelines held, and a timeline held under an additive entry mixing in. */
255
+ additiveHeld?: boolean;
256
+ /** An entry applied below alpha one half keeps none of its attachments — a threshold the runtime's `alphaAttachmentThreshold` of 0 does not have. */
257
+ attachmentsBelowHalfAlpha?: boolean;
177
258
  }
178
259
 
179
260
  // ---------------------------------------------------------------------------
@@ -206,6 +287,11 @@ interface MixAnimation {
206
287
  /** The empty animation (`AnimationState.emptyAnimation`): no timeline, duration 0 — one object, so setting it on an entry never applied replaces that entry. */
207
288
  const EMPTY: MixAnimation = { name: null, duration: 0, timelines: [], ids: new Set() };
208
289
 
290
+ /** `Timeline.additive`: the timelines the runtime adds under an additive entry — every bone timeline that keys numbers, and the deform. Colour, attachment, inherit and event timelines do not read `add`. */
291
+ function addable(tl: MixTimeline): boolean {
292
+ return tl.type === 'deform' || (tl.type === 'bone' && tl.kind !== 'inherit');
293
+ }
294
+
209
295
  /** The properties a bone timeline keys, spine-core's `propertyIds` by bone name. */
210
296
  const BONE_PROPERTIES: Record<BoneTimelineKind, readonly string[]> = {
211
297
  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'],
@@ -289,6 +375,10 @@ interface Entry {
289
375
  mixTime: number;
290
376
  mixDuration: number;
291
377
  totalAlpha: number;
378
+ /** `TrackEntry.alpha`. */
379
+ alpha: number;
380
+ /** `TrackEntry.additive`. */
381
+ additive: boolean;
292
382
  keepHold: boolean;
293
383
  timelineMode: number[];
294
384
  timelineHoldMix: Array<Entry | null | undefined>;
@@ -296,12 +386,12 @@ interface Entry {
296
386
  timelinesRotation: number[];
297
387
  }
298
388
 
299
- /** A new entry (`AnimationState.trackEntry`): every field the runtime's default; the mix duration 0, `AnimationStateData`'s default mix. */
389
+ /** A new entry (`AnimationState.trackEntry`): every field the runtime's default; the mix duration 0, `AnimationStateData`'s default mix, alpha 1, not additive. */
300
390
  function newEntry(anim: MixAnimation, loop: boolean): Entry {
301
391
  return {
302
392
  anim, loop, previous: null, next: null, mixingFrom: null, mixingTo: null,
303
393
  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,
394
+ animationLast: -1, nextAnimationLast: -1, mixTime: 0, mixDuration: 0, totalAlpha: 0, alpha: 1, additive: false, keepHold: false,
305
395
  timelineMode: [], timelineHoldMix: [], timelinesRotation: [],
306
396
  };
307
397
  }
@@ -535,6 +625,22 @@ class Mixer {
535
625
  return this.plant.mixReversed === true ? 1 - m : m;
536
626
  }
537
627
 
628
+ /** `TrackEntry.alpha` — inverted or ignored under the plants. */
629
+ alphaOf(e: Entry): number {
630
+ if (this.plant.alphaIgnored === true) return 1;
631
+ return this.plant.alphaInverted === true ? 1 - e.alpha : e.alpha;
632
+ }
633
+
634
+ /** `TrackEntry.additive` — never under the plant that applies an additive entry as a replacing one. */
635
+ additiveOf(e: Entry): boolean {
636
+ return e.additive && this.plant.additiveAsReplace !== true;
637
+ }
638
+
639
+ /** Whether `computeHold` reads an entry as additive — never under the plant that holds as if none were. */
640
+ private holdsAdditive(e: Entry | null): boolean {
641
+ return e !== null && this.additiveOf(e) && this.plant.additiveHeld !== true;
642
+ }
643
+
538
644
  update(delta: number): void {
539
645
  const tracks = this.tracks;
540
646
  for (let i = 0, n = tracks.length; i < n; i++) {
@@ -612,7 +718,7 @@ class Mixer {
612
718
  const current = tracks[i];
613
719
  if (current === null || current.delay > 0) continue;
614
720
  // Apply mixing from entries first.
615
- let alpha = 1;
721
+ let alpha = this.alphaOf(current);
616
722
  if (current.mixingFrom !== null) alpha *= this.applyMixingFrom(current, pose, i);
617
723
  else if (current.trackTime >= current.trackEnd && current.next === null) alpha = 0;
618
724
  // Apply the current entry.
@@ -621,20 +727,27 @@ class Mixer {
621
727
  const timelines = current.anim.timelines;
622
728
  const count = timelines.length;
623
729
  if (i === 0 && alpha === 1) {
730
+ // Track 0 at full weight poses from the setup pose and adds nothing, additive or not — the runtime passes `add` false here.
624
731
  for (const tl of timelines) {
625
732
  if (tl.type === 'attachment') this.applyAttachment(pose, tl, animationTime, SETUP, true);
626
- else this.applyTimeline(pose, tl, animationLast, animationTime, true, 1, SETUP, false);
733
+ else this.applyTimeline(pose, tl, animationLast, animationTime, true, 1, SETUP, false, false);
627
734
  }
628
735
  } else {
629
736
  const modes = current.timelineMode;
630
- const retain = alpha >= 0;
631
- const firstFrame = current.timelinesRotation.length !== count << 1;
737
+ // `alphaAttachmentThreshold` is 0: an entry keeps its attachments at any alpha, 0 included.
738
+ const retain = this.plant.attachmentsBelowHalfAlpha === true ? alpha >= 0.5 : alpha >= 0;
739
+ const add = this.additiveOf(current);
740
+ // `shortestRotation = add || …`: an additive entry's rotations go through the timeline itself, never the remembered direction.
741
+ const shortest = add && this.plant.additiveRotateRemembered !== true;
742
+ const firstFrame = !shortest && current.timelinesRotation.length !== count << 1;
632
743
  if (firstFrame) current.timelinesRotation.length = count << 1;
633
744
  timelines.forEach((tl, ii) => {
634
745
  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);
746
+ if (!shortest && tl.type === 'bone' && tl.kind === 'rotate') this.applyRotate(pose, tl, animationTime, alpha, from, current.timelinesRotation, ii << 1, firstFrame);
747
+ else if (tl.type === 'attachment') {
748
+ if (add && this.plant.additiveAttachmentsDirect === true) this.applyAttachmentDirect(pose, tl, animationTime, from, false);
749
+ else this.applyAttachment(pose, tl, animationTime, from, retain);
750
+ } else this.applyTimeline(pose, tl, animationLast, animationTime, true, alpha, from, false, add);
638
751
  });
639
752
  }
640
753
  if (i === 0) base = animationTime;
@@ -657,15 +770,17 @@ class Mixer {
657
770
  const from = to.mixingFrom as Entry;
658
771
  const fromMix = from.mixingFrom !== null ? this.applyMixingFrom(from, pose, track) : 1;
659
772
  const mix = this.mixOf(to);
660
- const a = 1 * fromMix;
661
- const keep = 1 - mix * 1;
773
+ const a = this.alphaOf(from) * fromMix;
774
+ const keep = 1 - mix * (this.plant.holdIgnoresEntryAlpha === true ? 1 : this.alphaOf(to));
662
775
  const alphaMix = a * (1 - mix);
663
776
  const alphaHold = keep > 0 ? alphaMix / keep : a;
664
777
  const timelines = from.anim.timelines;
665
778
  const count = timelines.length;
666
779
  const modes = from.timelineMode;
667
780
  const holdMix = from.timelineHoldMix;
668
- const firstFrame = from.timelinesRotation.length !== count << 1;
781
+ const add = this.additiveOf(from);
782
+ const shortest = add && this.plant.additiveRotateRemembered !== true;
783
+ const firstFrame = !shortest && from.timelinesRotation.length !== count << 1;
669
784
  if (firstFrame) from.timelinesRotation.length = count << 1;
670
785
  const animationLast = from.animationLast;
671
786
  const animationTime = animationTimeOf(from);
@@ -680,10 +795,15 @@ class Mixer {
680
795
  alpha = h === null || h === undefined ? alphaHold : alphaHold * (1 - this.mixOf(h));
681
796
  } else alpha = alphaMix;
682
797
  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);
798
+ if (!shortest && tl.type === 'bone' && tl.kind === 'rotate') this.applyRotate(pose, tl, animationTime, alpha, mixFrom, from.timelinesRotation, i << 1, firstFrame);
799
+ else if (tl.type === 'attachment') {
800
+ // `mixAttachmentThreshold` is 0: an entry mixing out never keeps an attachment, whatever its alpha.
801
+ if (add && this.plant.additiveAttachmentsDirect === true) this.applyAttachmentDirect(pose, tl, animationTime, mixFrom, true);
802
+ else this.applyAttachment(pose, tl, animationTime, mixFrom, false);
803
+ }
685
804
  // 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);
805
+ // An additive one mixing out adds at the faded alpha — its additive timelines are never held (`computeHold`).
806
+ else this.applyTimeline(pose, tl, animationLast, animationTime, fire, alpha, mixFrom, true, add);
687
807
  });
688
808
  from.nextAnimationLast = animationTime;
689
809
  from.nextTrackLast = from.trackTime;
@@ -712,16 +832,23 @@ class Mixer {
712
832
  const holdMix = entry.timelineHoldMix;
713
833
  holdMix.length = 0;
714
834
  const to = entry.mixingTo;
835
+ const add = this.holdsAdditive(entry);
836
+ const toAdds = this.holdsAdditive(to);
715
837
  timelines.forEach((tl, i) => {
716
838
  const from = this.propertyFrom(track, tl.ids);
839
+ // An additive entry's additive timelines are never held, and the entry's keepHold does not reach them.
840
+ if (add && addable(tl)) {
841
+ modes[i] = from;
842
+ return;
843
+ }
717
844
  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;
845
+ // Hold if the next entry will overwrite this property — an additive one adding to it does not.
846
+ if (to === null || tl.instant || (toAdds && addable(tl)) || !hasAny(to.anim, tl.ids)) mode = from;
720
847
  else {
721
848
  mode = from | HOLD;
722
849
  // Find the next entry that doesn't overwrite this property. Its mix fades out the hold, instead of it ending abruptly.
723
850
  for (let next = to.mixingTo; next !== null; next = next.mixingTo) {
724
- if (!hasAny(next.anim, tl.ids)) {
851
+ if ((this.holdsAdditive(next) && addable(tl)) || !hasAny(next.anim, tl.ids)) {
725
852
  if (next.mixDuration > 0) holdMix[i] = next;
726
853
  break;
727
854
  }
@@ -761,12 +888,12 @@ class Mixer {
761
888
  return keys[index].values.map((_v, c) => channel(keys, index, c, t));
762
889
  }
763
890
 
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 {
891
+ /** `Timeline.apply` for every timeline but an attachment's and a rotation mixed below full weight; `add` is the entry's, read only by the timelines the runtime adds (`addable`). */
892
+ private applyTimeline(pose: Pose, tl: MixTimeline, lastTime: number, time: number, fire: boolean, alpha: number, from: number, out: boolean, add: boolean): void {
766
893
  switch (tl.type) {
767
- case 'bone': return this.applyBone(pose, tl, time, alpha, from, out);
894
+ case 'bone': return this.applyBone(pose, tl, time, alpha, from, out, add);
768
895
  case 'colour': return this.applyColour(pose, tl, time, alpha, from);
769
- case 'deform': return this.applyDeform(pose, tl, time, alpha, from);
896
+ case 'deform': return this.applyDeform(pose, tl, time, alpha, from, add);
770
897
  case 'events':
771
898
  if (fire) pose.events.push(...eventsFired(tl.keys, lastTime, time, rawNumber, time < lastTime));
772
899
  return;
@@ -774,7 +901,7 @@ class Mixer {
774
901
  }
775
902
  }
776
903
 
777
- private applyBone(pose: Pose, tl: Extract<MixTimeline, { type: 'bone' }>, time: number, alpha: number, from: number, out: boolean): void {
904
+ private applyBone(pose: Pose, tl: Extract<MixTimeline, { type: 'bone' }>, time: number, alpha: number, from: number, out: boolean, add: boolean): void {
778
905
  const bone = pose.bone(tl.bone);
779
906
  if (bone === null) return;
780
907
  const setup = this.doc.bones[tl.bone];
@@ -797,7 +924,8 @@ class Mixer {
797
924
  const relative = (field: 'x' | 'y' | 'rotation' | 'shearX' | 'shearY', i: number): void => {
798
925
  const s = setup[field] ?? 0;
799
926
  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;
927
+ // `getRelativeValue`: from the setup pose the same either way; else the keyed value added, or the value over the setup mixed in.
928
+ bone[field] = v === null ? beforeFirstKey(from, alpha, c, s) : from === SETUP ? s + v[i] * alpha : c + (add ? v[i] : v[i] + s - c) * alpha;
801
929
  };
802
930
  const pair = (fx: 'x' | 'shearX', fy: 'y' | 'shearY'): void => {
803
931
  const sx = setup[fx] ?? 0;
@@ -815,6 +943,9 @@ class Mixer {
815
943
  if (from === SETUP) {
816
944
  bone[fx] = sx + v[0] * alpha;
817
945
  bone[fy] = sy + v[1] * alpha;
946
+ } else if (add) {
947
+ bone[fx] = (bone[fx] ?? 0) + v[0] * alpha;
948
+ bone[fy] = (bone[fy] ?? 0) + v[1] * alpha;
818
949
  } else {
819
950
  bone[fx] = (bone[fx] ?? 0) + (sx + v[0] - (bone[fx] ?? 0)) * alpha;
820
951
  bone[fy] = (bone[fy] ?? 0) + (sy + v[1] - (bone[fy] ?? 0)) * alpha;
@@ -823,7 +954,7 @@ class Mixer {
823
954
  const scale = (field: 'scaleX' | 'scaleY', i: number): void => {
824
955
  const s = setup[field] ?? 1;
825
956
  const c = bone[field] ?? 1;
826
- bone[field] = v === null ? beforeFirstKey(from, alpha, c, s) : scaleValue(v[i] * s, alpha, from, out, c, s);
957
+ bone[field] = v === null ? beforeFirstKey(from, alpha, c, s) : scaleValue(v[i] * s, alpha, from, out, add, c, s);
827
958
  };
828
959
  switch (tl.kind) {
829
960
  case 'rotate': relative('rotation', 0); break;
@@ -845,8 +976,8 @@ class Mixer {
845
976
  bone.scaleY = (bone.scaleY ?? 1) + (sy - (bone.scaleY ?? 1)) * alpha;
846
977
  }
847
978
  } 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);
979
+ bone.scaleX = scaleValue(v[0] * (setup.scaleX ?? 1), alpha, from, out, add, bone.scaleX ?? 1, setup.scaleX ?? 1);
980
+ bone.scaleY = scaleValue(v[1] * (setup.scaleY ?? 1), alpha, from, out, add, bone.scaleY ?? 1, setup.scaleY ?? 1);
850
981
  }
851
982
  break;
852
983
  case 'scalex': scale('scaleX', 0); break;
@@ -858,7 +989,7 @@ class Mixer {
858
989
  private applyRotate(pose: Pose, tl: Extract<MixTimeline, { type: 'bone' }>, time: number, alpha: number, from: number, rotations: number[], i: number, firstFrame: boolean): void {
859
990
  if (firstFrame) rotations[i] = 0;
860
991
  if (alpha === 1) {
861
- this.applyBone(pose, tl, time, 1, from, false);
992
+ this.applyBone(pose, tl, time, 1, from, false, false);
862
993
  return;
863
994
  }
864
995
  const bone = pose.bone(tl.bone);
@@ -934,6 +1065,15 @@ class Mixer {
934
1065
  else if (!setup) s.attachmentState = ATTACH_SETUP;
935
1066
  }
936
1067
 
1068
+ /** `AttachmentTimeline.apply` — the plain timeline, writing no attachment state: only `additiveAttachmentsDirect` reaches it, the reading `CO52` rejects. */
1069
+ private applyAttachmentDirect(pose: Pose, tl: Extract<MixTimeline, { type: 'attachment' }>, time: number, from: number, out: boolean): void {
1070
+ if (!pose.slotLive(tl.slot)) return;
1071
+ const index = (this.plant.search ?? keyIndexAt)(tl.keys, time);
1072
+ if (out || index < 0) {
1073
+ if (from !== CURRENT) pose.setAttachment(tl.slot, this.doc.slots[tl.slot].setup);
1074
+ } else pose.setAttachment(tl.slot, tl.keys[index].name ?? null);
1075
+ }
1076
+
937
1077
  private applyColour(pose: Pose, tl: Extract<MixTimeline, { type: 'colour' }>, time: number, alpha: number, from: number): void {
938
1078
  if (!pose.slotLive(tl.slot)) return;
939
1079
  const slot = this.doc.slots[tl.slot];
@@ -1030,7 +1170,7 @@ class Mixer {
1030
1170
  }
1031
1171
 
1032
1172
  /** `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 {
1173
+ private applyDeform(pose: Pose, tl: Extract<MixTimeline, { type: 'deform' }>, time: number, alpha: number, from: number, add: boolean): void {
1034
1174
  const keys = tl.timeline.deform;
1035
1175
  if (keys === null) return;
1036
1176
  pose.slots.forEach((s, k) => {
@@ -1061,13 +1201,27 @@ class Mixer {
1061
1201
  }
1062
1202
  return;
1063
1203
  }
1064
- // `applyToSlot`, never additive.
1065
- if (alpha === 1) s.deform = [...target];
1066
- else if (mixFrom === SETUP) {
1204
+ // `applyToSlot`: added only over a deform already there — over none it is the setup pose's branch, additive or not.
1205
+ const fromSetup = mixFrom === SETUP;
1206
+ if (alpha === 1) {
1207
+ if (add && !fromSetup) {
1208
+ const current = s.deform as number[];
1209
+ if (!vertices.weighted) {
1210
+ const setup = setupArray(vertices);
1211
+ s.deform = current.map((c, i) => c + (target[i] - setup[i]));
1212
+ } else s.deform = current.map((c, i) => c + target[i]);
1213
+ } else s.deform = [...target];
1214
+ } else if (fromSetup) {
1067
1215
  if (!vertices.weighted) {
1068
1216
  const setup = setupArray(vertices);
1069
1217
  s.deform = target.map((v, i) => setup[i] + (v - setup[i]) * alpha);
1070
1218
  } else s.deform = target.map((v) => v * alpha);
1219
+ } else if (add) {
1220
+ const current = s.deform as number[];
1221
+ if (!vertices.weighted) {
1222
+ const setup = setupArray(vertices);
1223
+ s.deform = current.map((c, i) => c + (target[i] - setup[i]) * alpha);
1224
+ } else s.deform = current.map((c, i) => c + target[i] * alpha);
1071
1225
  } else {
1072
1226
  const current = s.deform as number[];
1073
1227
  s.deform = current.map((c, i) => c + (target[i] - c) * alpha);
@@ -1083,10 +1237,11 @@ function beforeFirstKey(from: number, alpha: number, current: number, setup: num
1083
1237
  return current;
1084
1238
  }
1085
1239
 
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;
1240
+ /** `CurveTimeline1.getScaleValue` past the first key, `value` the curve times the setup scale; added, its offset from the setup scale is added to the base. */
1241
+ function scaleValue(value: number, alpha: number, from: number, out: boolean, add: boolean, current: number, setup: number): number {
1242
+ if (alpha === 1 && !add) return value;
1089
1243
  let base = from === SETUP ? setup : current;
1244
+ if (add) return base + (value - setup) * alpha;
1090
1245
  if (out) return base + (Math.abs(value) * Math.sign(base) - base) * alpha;
1091
1246
  base = Math.abs(base) * Math.sign(value);
1092
1247
  return base + (value - base) * alpha;
@@ -1117,6 +1272,20 @@ function loopOf(options: MixOptions | undefined, call: string): boolean {
1117
1272
  return options.loop;
1118
1273
  }
1119
1274
 
1275
+ /** An alpha: a finite number from 0 to 1, or the call refused naming it — the runtime clamps nothing and defines no weight outside it. */
1276
+ function alphaValue(v: unknown, what: string, call: string): number {
1277
+ if (typeof v !== 'number' || !Number.isFinite(v) || v < 0 || v > 1) throw new CoreInputError(`the live tracks' ${call} ${what} is ${typeof v === 'number' ? String(v) : JSON.stringify(v)}, not a number from 0 to 1`);
1278
+ return v;
1279
+ }
1280
+
1281
+ /** `options.alpha` and `options.additive`: 1 and `false` when absent, else refused by name where they are not a weight and a boolean. */
1282
+ function blendOf(options: MixOptions, call: string): { alpha: number; additive: boolean } {
1283
+ const alpha = options.alpha === undefined ? 1 : alphaValue(options.alpha, 'options.alpha', call);
1284
+ const additive: unknown = options.additive;
1285
+ if (additive !== undefined && typeof additive !== 'boolean') throw new CoreInputError(`the live tracks' ${call} options.additive is ${JSON.stringify(additive)}, not true or false — an entry adds or it does not`);
1286
+ return { alpha, additive: additive === true };
1287
+ }
1288
+
1120
1289
  function delayOf(v: number, call: string): number {
1121
1290
  if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`the live tracks' ${call} delay is ${String(v)}, not a finite number`);
1122
1291
  return v;
@@ -1163,8 +1332,11 @@ class Tracks implements LiveTracks {
1163
1332
  const i = trackIndex(track, 'setAnimation');
1164
1333
  const loop = loopOf(options, 'setAnimation');
1165
1334
  const mix = finiteAtOrAbove0(options.mixDuration, 'mixDuration', 'setAnimation');
1335
+ const blend = blendOf(options, 'setAnimation');
1166
1336
  const entry = this.mixer.setAnimation(i, this.mixer.animation(animation), loop);
1167
1337
  entry.mixDuration = mix;
1338
+ entry.alpha = blend.alpha;
1339
+ entry.additive = blend.additive;
1168
1340
  }
1169
1341
 
1170
1342
  setEmptyAnimation(track: number, mixDuration: number): void {
@@ -1182,8 +1354,11 @@ class Tracks implements LiveTracks {
1182
1354
  const loop = loopOf(options, 'addAnimation');
1183
1355
  const mix = finiteAtOrAbove0(options.mixDuration, 'mixDuration', 'addAnimation');
1184
1356
  const delay = delayOf(options.delay, 'addAnimation');
1357
+ const blend = blendOf(options, 'addAnimation');
1185
1358
  const entry = this.mixer.addAnimation(i, this.mixer.animation(animation), loop, delay);
1186
1359
  this.mixer.setMixDuration(entry, mix, delay);
1360
+ entry.alpha = blend.alpha;
1361
+ entry.additive = blend.additive;
1187
1362
  }
1188
1363
 
1189
1364
  addEmptyAnimation(track: number, mixDuration: number, delay: number): void {
@@ -1197,10 +1372,19 @@ class Tracks implements LiveTracks {
1197
1372
  entry.trackEnd = mix;
1198
1373
  }
1199
1374
 
1375
+ setAlpha(track: number, alpha: number): void {
1376
+ this.live('setAlpha');
1377
+ const i = trackIndex(track, 'setAlpha');
1378
+ const a = alphaValue(alpha, 'alpha', 'setAlpha');
1379
+ const e = this.mixer.tracks[i] ?? null;
1380
+ if (e === null) throw new CoreInputError(`the live tracks' setAlpha names track ${i}, which plays nothing — there is no entry to weigh`);
1381
+ e.alpha = a;
1382
+ }
1383
+
1200
1384
  current(track: number): TrackEntryView | null {
1201
1385
  const e = this.mixer.tracks[trackIndex(track, 'current')] ?? null;
1202
1386
  if (e === null) return null;
1203
- return { animation: e.anim.name, loop: e.loop, trackTime: e.trackTime, animationTime: animationTimeOf(e), duration: e.anim.duration };
1387
+ return { animation: e.anim.name, loop: e.loop, trackTime: e.trackTime, animationTime: animationTimeOf(e), duration: e.anim.duration, alpha: e.alpha, additive: e.additive };
1204
1388
  }
1205
1389
 
1206
1390
  start(): RawPose {