spine-rigc 0.25.2 → 0.25.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/validate.ts CHANGED
@@ -22,16 +22,19 @@ import {
22
22
  type BoneData,
23
23
  BoundingBoxAttachment,
24
24
  ClippingAttachment,
25
+ type ConstraintTimeline,
25
26
  DeformTimeline,
26
27
  isBoneTimeline,
27
28
  isConstraintTimeline,
28
29
  isSlotTimeline,
29
30
  MeshAttachment,
31
+ MixFrom,
30
32
  PathAttachment,
31
33
  PathConstraintData,
32
34
  Physics,
33
35
  PhysicsConstraintData,
34
36
  PhysicsConstraintPose,
37
+ PhysicsConstraintResetTimeline,
35
38
  PhysicsConstraintTimeline,
36
39
  Property,
37
40
  RegionAttachment,
@@ -181,6 +184,7 @@ const ASSERTION_KIND: Record<string, 'validity' | 'renderer' | 'archetype'> = {
181
184
  A39_DEFORM_KEEPS_TRIANGLE_WINDING: 'archetype',
182
185
  A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET: 'validity',
183
186
  A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: 'validity',
187
+ A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
184
188
  };
185
189
 
186
190
  /**
@@ -662,6 +666,176 @@ function deformArrayLength(att: Json): number | null {
662
666
  return influences * 2;
663
667
  }
664
668
 
669
+ /**
670
+ * What one timeline's own `apply` does with the `add` argument.
671
+ *
672
+ * - `accumulates` — a second application adds to the first, so two sliders both
673
+ * `additive` compose on it.
674
+ * - `overwrites` — it writes its value whatever `add` says, so the later slider
675
+ * owns the property and `"additive": true` is not the repair.
676
+ * - `inert` — applied with the arguments a slider passes it changes nothing a
677
+ * pose holds, so there is nothing for a second slider to erase.
678
+ */
679
+ export type TimelineAddBehaviour = 'accumulates' | 'overwrites' | 'inert';
680
+
681
+ /**
682
+ * The displacement the second start state carries, chosen to be exact in binary
683
+ * floating point so the probe adds no rounding of its own.
684
+ */
685
+ const PROBE_DISPLACEMENT = 0.375;
686
+
687
+ /** Every own value of a pose object a timeline could have written, as text. */
688
+ function poseReading(pose: object): string {
689
+ const parts: string[] = [];
690
+ for (const key of Object.keys(pose).sort()) {
691
+ const value = (pose as Record<string, unknown>)[key];
692
+ if (value === null || value === undefined || typeof value === 'number' || typeof value === 'boolean' || typeof value === 'string') {
693
+ parts.push(`${key}=${String(value)}`);
694
+ continue;
695
+ }
696
+ if (Array.isArray(value)) {
697
+ const entries: unknown[] = value;
698
+ if (entries.every((one) => typeof one === 'number')) parts.push(`${key}=[${entries.join(',')}]`);
699
+ continue;
700
+ }
701
+ if (typeof value !== 'object') continue;
702
+ const record = value as Record<string, unknown>;
703
+ const keys = Object.keys(record).sort();
704
+ // A colour, or anything else built only of numbers. Everything with a
705
+ // structure — a bone back-reference, a slot's data — is either an identity
706
+ // (read by name below) or something no timeline writes.
707
+ if (keys.length > 0 && keys.every((one) => typeof record[one] === 'number')) {
708
+ parts.push(`${key}={${keys.map((one) => `${one}:${String(record[one])}`).join(',')}}`);
709
+ } else if (typeof record.name === 'string') {
710
+ parts.push(`${key}=@${record.name}`);
711
+ }
712
+ }
713
+ return parts.join(',');
714
+ }
715
+
716
+ /** The whole of what a timeline could have changed, in one comparable string. */
717
+ function skeletonReading(skeleton: Skeleton): string {
718
+ const parts: string[] = [];
719
+ for (const bone of skeleton.bones) parts.push(poseReading(bone.appliedPose));
720
+ for (const slot of skeleton.slots) parts.push(poseReading(slot.appliedPose));
721
+ for (const constraint of skeleton.constraints) parts.push(poseReading(constraint.appliedPose as object));
722
+ parts.push(skeleton.drawOrder.appliedPose.map((slot) => slot.data.name).join('>'));
723
+ return parts.join('|');
724
+ }
725
+
726
+ /**
727
+ * The two start states a probe is taken from, in order: the setup pose, and the
728
+ * setup pose displaced.
729
+ *
730
+ * ⚠️ Both are needed and neither is redundant. From the **setup** pose a
731
+ * `DeformTimeline` is live, because its `apply` is gated on the slot still
732
+ * holding the attachment the timeline names. From the **displaced** pose a
733
+ * timeline whose every key states its target's own setup value is still seen to
734
+ * write, which from the setup pose alone would read as `inert` — a later slider
735
+ * keying a slot's colour to exactly the colour it already has erases an earlier
736
+ * one just the same.
737
+ */
738
+ function probeStartStates(skeleton: Skeleton): Array<() => void> {
739
+ return [
740
+ () => undefined,
741
+ () => {
742
+ for (const bone of skeleton.bones) displacePose(bone.appliedPose);
743
+ for (const slot of skeleton.slots) {
744
+ displacePose(slot.appliedPose);
745
+ slot.appliedPose.setAttachment(null);
746
+ }
747
+ for (const constraint of skeleton.constraints) displacePose(constraint.appliedPose as object);
748
+ skeleton.drawOrder.appliedPose.push(...skeleton.drawOrder.appliedPose.splice(0, 1));
749
+ },
750
+ ];
751
+ }
752
+
753
+ /** Move every number a pose holds, so "it wrote nothing" cannot mean "it wrote what was there". */
754
+ function displacePose(pose: object): void {
755
+ for (const key of Object.keys(pose)) {
756
+ const value = (pose as Record<string, unknown>)[key];
757
+ if (typeof value === 'number') {
758
+ (pose as Record<string, number>)[key] = value + PROBE_DISPLACEMENT;
759
+ continue;
760
+ }
761
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) continue;
762
+ const record = value as Record<string, unknown>;
763
+ const keys = Object.keys(record);
764
+ if (keys.length > 0 && keys.every((one) => typeof record[one] === 'number')) {
765
+ for (const one of keys) (record as Record<string, number>)[one] += PROBE_DISPLACEMENT;
766
+ }
767
+ }
768
+ }
769
+
770
+ /** Every time the timeline's own frames name, the midpoints between them, and one past the end. */
771
+ function probeTimes(timeline: Timeline): number[] {
772
+ const stride = timeline.getFrameEntries();
773
+ const frames = timeline.frames;
774
+ const at: number[] = [];
775
+ for (let i = 0; i < frames.length; i += stride) at.push(frames[i]);
776
+ const between: number[] = [];
777
+ for (let i = 1; i < at.length; i++) between.push((at[i - 1] + at[i]) / 2);
778
+ return [...at, ...between, (at.length ? at[at.length - 1] : 0) + 1];
779
+ }
780
+
781
+ /**
782
+ * 🚨 **What `apply` does with `add`, posed rather than read off a flag.**
783
+ *
784
+ * `Timeline.additive` is the runtime's own declaration that a class "supports
785
+ * being applied additively", and for two classes it is not what the class does:
786
+ * `PathConstraintMixTimeline` and `SliderTimeline` declare `false` and pass the
787
+ * `add` argument straight through anyway. A40 read the flag, so it refused two
788
+ * correct rigs with a sentence about the runtime the runtime does not perform
789
+ * (issue #655, measured by `PS143` over all thirty spellings of the motion
790
+ * vocabulary).
791
+ *
792
+ * So the answer is taken from the object instead. The timeline is applied with
793
+ * exactly the arguments `Slider.update` passes — `firedEvents` null, `alpha` 1,
794
+ * `MixFrom.current`, `appliedPose` true — **twice**, at every time its own
795
+ * frames name. A second application that moves the pose again is a class that
796
+ * honours `add`; one that lands on the same value is a class that writes
797
+ * outright; one that never moves the pose at all writes nothing a second slider
798
+ * could erase, which is what an events timeline under a slider *is*.
799
+ *
800
+ * ⛔ Rejected: reading the source of `apply` (not a measurement of anything, and
801
+ * a class whose body changes under a runtime bump would still read the old
802
+ * answer), and a table of the two disagreeing classes written here (the same
803
+ * hand-kept list this repository has a judgment about — it would have been
804
+ * written after the census found two and been wrong at the third).
805
+ *
806
+ * ⚠️ **The skeleton wears every skin in turn, and that is not thoroughness.**
807
+ * `Skeleton.updateCache` leaves a `skinRequired` constraint inactive under the
808
+ * skins that do not list it, and an inactive target makes ANY timeline read
809
+ * `inert` — a fact about that skin, not about the class. Reading one skin would
810
+ * put a whole class in the third state and quietly stop refusing pairs that
811
+ * share it, which is this repository's worst failure shape: a gate that looks
812
+ * kept while checking nothing. The strongest verdict any skin produces wins.
813
+ */
814
+ export function timelineAddBehaviour(data: ReturnType<SkeletonJson['readSkeletonData']>, timeline: Timeline): TimelineAddBehaviour {
815
+ let wrote = false;
816
+ for (const skin of [null, ...data.skins]) {
817
+ const skeleton = new Skeleton(data);
818
+ if (skin !== null) skeleton.setSkin(skin);
819
+ for (const displace of probeStartStates(skeleton)) {
820
+ for (const time of probeTimes(timeline)) {
821
+ skeleton.setupPose();
822
+ for (const bone of skeleton.bones) bone.resetConstrained();
823
+ for (const slot of skeleton.slots) slot.resetConstrained();
824
+ for (const constraint of skeleton.constraints) constraint.resetConstrained();
825
+ skeleton.drawOrder.resetConstrained();
826
+ displace();
827
+ const before = skeletonReading(skeleton);
828
+ timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
829
+ const once = skeletonReading(skeleton);
830
+ timeline.apply(skeleton, time, time, null, 1, MixFrom.current, true, false, true);
831
+ if (skeletonReading(skeleton) !== once) return 'accumulates';
832
+ if (once !== before) wrote = true;
833
+ }
834
+ }
835
+ }
836
+ return wrote ? 'overwrites' : 'inert';
837
+ }
838
+
665
839
  export function validate(input: ValidateInput): ValidateReport {
666
840
  const failures: Failure[] = [];
667
841
  const passed: string[] = [];
@@ -2855,17 +3029,43 @@ export function validate(input: ValidateInput): ValidateReport {
2855
3029
  //
2856
3030
  // ## The second clause: `additive: true` is not always available
2857
3031
  //
2858
- // `Timeline.additive` is the runtime's own flag for "supports being applied
2859
- // additively", and it is true for only some of them — bone timelines,
2860
- // deform, transform-constraint, path position, physics wind and gravity, and
2861
- // a slider's own mix. A slot colour, an attachment swap, a draw order or a
2862
- // sequence IGNORES the `add` argument entirely (`RGBATimeline.apply1` takes
2863
- // it and never reads it), so two sliders sharing one of those overwrite each
3032
+ // A slot colour, an attachment swap, a draw order, an ik mix and a path's
3033
+ // spacing IGNORE the `add` argument entirely (`RGBATimeline.apply1` takes it
3034
+ // and never reads it), so two sliders sharing one of those overwrite each
2864
3035
  // other whatever the flags say. Refusing that case too is what keeps this
2865
3036
  // message from teaching a fix that does not work: an author told to set
2866
3037
  // `additive: true` on a shared rgba would get a green gate over the same
2867
- // dead axis. Reading the runtime's flag rather than a list written here is
2868
- // also what keeps the rule from drifting when that list changes.
3038
+ // dead axis.
3039
+ //
3040
+ // 🚨 **Which class is which is MEASURED, and reading the flag was wrong.**
3041
+ // This clause used to ask `Timeline.additive`, the runtime's own declaration
3042
+ // that a class supports additive application — and two classes declare
3043
+ // `false` and honour `add` anyway. `PathConstraintMixTimeline` and
3044
+ // `SliderTimeline` pass the argument straight through, so two additive
3045
+ // sliders keying one path constraint's `mix`, or one bone-less slider's
3046
+ // `time`, **do** compose as the plain sum, and this assertion refused them
3047
+ // by name with a sentence about the runtime the runtime does not perform
3048
+ // (issue #655; `PS143` poses all thirty spellings of the motion vocabulary
3049
+ // and `PS140` holds the `time` case to a grid). `timelineAddBehaviour`
3050
+ // above poses each shared timeline instead, so what is compared is what the
3051
+ // class does rather than what it says about itself.
3052
+ //
3053
+ // ## The events clause, removed rather than narrowed
3054
+ //
3055
+ // The same reading refused two sliders whose animations both fire events,
3056
+ // and that refusal was over a property no pose can distinguish: `Slider`
3057
+ // applies its animation with `firedEvents` **null**
3058
+ // (`Slider.js`: `animation.apply(skeleton, p.time, p.time, data.loop, null,
3059
+ // …)`), and `EventTimeline.apply` opens with `if (!firedEvents) return`, so
3060
+ // a slider fires no event at all. Neither slider has anything on that
3061
+ // property for the other to erase. The same is true of a physics `reset`
3062
+ // under a slider, where `lastTime === time` leaves its window empty.
3063
+ //
3064
+ // ⭐ Neither is named here, and that is the point: the probe's third state
3065
+ // is **a timeline that writes nothing at all**, so both fall out of one
3066
+ // measurement instead of two exceptions. A list of unobservable spellings
3067
+ // written into this file would have been the hand-kept table that produced
3068
+ // the defect above.
2869
3069
  check('A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET', () => {
2870
3070
  const sliders = data.constraints.filter((c) => c instanceof SliderData);
2871
3071
  if (sliders.length < 2) {
@@ -2931,6 +3131,15 @@ export function validate(input: ValidateInput): ValidateReport {
2931
3131
  }
2932
3132
  }
2933
3133
  }
3134
+ /** Posed once per timeline, because the answer is the class's and the rigs that reach here share timelines. */
3135
+ const behaviour = new Map<Timeline, TimelineAddBehaviour>();
3136
+ const addBehaviourOf = (timeline: Timeline): TimelineAddBehaviour => {
3137
+ const known = behaviour.get(timeline);
3138
+ if (known !== undefined) return known;
3139
+ const measured = timelineAddBehaviour(data, timeline);
3140
+ behaviour.set(timeline, measured);
3141
+ return measured;
3142
+ };
2934
3143
  let shared = 0;
2935
3144
  for (const [id, users] of byProperty) {
2936
3145
  if (users.length < 2) continue;
@@ -2940,21 +3149,27 @@ export function validate(input: ValidateInput): ValidateReport {
2940
3149
  const chain = users.map((u) => at(u.slider)).join(', ');
2941
3150
  for (let j = 1; j < users.length; j++) {
2942
3151
  const later = users[j];
2943
- const composes = later.timeline.additive;
2944
- if (composes && later.slider.additive) continue;
3152
+ const composes = addBehaviourOf(later.timeline);
3153
+ if (composes === 'accumulates' && later.slider.additive) continue;
3154
+ // Nothing a second slider could take away: applied with the arguments
3155
+ // a slider passes — `firedEvents` null among them — this timeline
3156
+ // moves no pose at all.
3157
+ if (composes === 'inert') continue;
2945
3158
  const erased = users.slice(0, j).filter((e) => canOverlap(e.slider, later.slider));
2946
3159
  if (!erased.length) continue;
2947
3160
  const erasedNames = `${erased.map((e) => `"${e.slider.name}"`).join(', ')} contribute${erased.length === 1 ? 's' : ''}`;
2948
- const why = composes
2949
- ? `slider "${later.slider.name}" applies animation "${later.slider.animation?.name}" with additive false, and at ` +
2950
- 'mix 1 a non-additive apply writes the value outright (`getRelativeValue` returns `setup + value`, ' +
2951
- '`getAbsoluteValue` returns `value`) rather than adding to the pose it found. Set `"additive": true` on ' +
2952
- `slider "${later.slider.name}" in the rig spec — rigc will not choose that flag for you — or key this ` +
2953
- 'property from one slider only'
2954
- : `the ${Property[Number(id.split('|')[0])] ?? id} timeline they share does not support additive application at all ` +
2955
- '(`Timeline.additive` is false for it and its `apply` ignores the `add` argument), so `"additive": true` ' +
2956
- `would NOT compose these — slider "${later.slider.name}" overwrites whatever the flags say. Key this ` +
2957
- 'property from one slider only, or move both edits into the one animation a single slider applies';
3161
+ const why =
3162
+ composes === 'accumulates'
3163
+ ? `slider "${later.slider.name}" applies animation "${later.slider.animation?.name}" with additive false, and at ` +
3164
+ 'mix 1 a non-additive apply writes the value outright (`getRelativeValue` returns `setup + value`, ' +
3165
+ '`getAbsoluteValue` returns `value`) rather than adding to the pose it found. Set `"additive": true` on ' +
3166
+ `slider "${later.slider.name}" in the rig spec — rigc will not choose that flag for you — or key this ` +
3167
+ `property from one slider only. [measured] \`${later.timeline.constructor.name}.apply\` posed twice with ` +
3168
+ '`add` set accumulates, so that flag is the repair here'
3169
+ : `the ${Property[Number(id.split('|')[0])] ?? id} timeline they share writes its value outright whatever the ` +
3170
+ `flags say — [measured] \`${later.timeline.constructor.name}.apply\` posed twice with \`add\` set left the ` +
3171
+ 'same value there rather than adding to it — so `"additive": true` would NOT compose these. Key this ' +
3172
+ 'property from one slider only, or move both edits into the one animation a single slider applies';
2958
3173
  fail(
2959
3174
  'A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET',
2960
3175
  `${describe(later.timeline, id)} is keyed by the animations of ${users.length} sliders — ${chain} — and every ` +
@@ -2966,6 +3181,174 @@ export function validate(input: ValidateInput): ValidateReport {
2966
3181
  stats.sliderSharedTargets = shared;
2967
3182
  });
2968
3183
 
3184
+ // --- A42: a dial that drives a constraint the array already ran ---------
3185
+ //
3186
+ // 🚨 The hole A40 leaves, and it leaves it BY CONSTRUCTION. A40's population
3187
+ // is the sliders at full authority whose own `mix` nothing keys, so the one
3188
+ // pair this rule is about — a slider whose `mix` IS keyed — is excluded
3189
+ // before any timeline is looked at. Between them the two rules ask different
3190
+ // questions about the same array: A40 asks who writes a shared property
3191
+ // last, this asks whether anybody reads what was written at all.
3192
+ //
3193
+ // `Skeleton.updateCache` walks `constraints` in order and each constraint's
3194
+ // `sort` pushes itself onto the update cache as it is reached — `sortBone`
3195
+ // pushes bones and never a constraint — so the array IS the update order,
3196
+ // for every kind. Every constraint then opens its `update` by reading its
3197
+ // own applied pose: `Slider.update` takes `mix` and `time` off it before
3198
+ // applying its animation, `PhysicsConstraint.update` returns on `mix === 0`
3199
+ // before reading `inertia`, `wind` and the rest, and the ik, transform and
3200
+ // path constraints read their mixes the same way. So a slider that keys any
3201
+ // property of a constraint is read by that constraint only when it comes
3202
+ // LATER in the array; written the other way round the value lands in a pose
3203
+ // whose only reader has already run, and `Skeleton.updateWorldTransform`
3204
+ // opens the next frame by putting `Posed.resetConstrained` over it.
3205
+ //
3206
+ // [measured, issue #665] One dial and one constraint, in both array orders,
3207
+ // read at 6 positions of the dial. The constraint's own pose holds the same
3208
+ // ramp in BOTH orders — the write happens either way — and what the
3209
+ // constraint drives is dead in one of them:
3210
+ //
3211
+ // ik shin worldY 0.000e+0 against 1.662e+1 the other way round
3212
+ // transform shin world x-angle 0.000e+0 against 4.000e+1
3213
+ // path rider worldX 0.000e+0 against 1.620e+2
3214
+ // physics tip worldX 0.000e+0 against 4.256e+2, over 30 frames, so
3215
+ // the one kind with state of its own is not rescued by it
3216
+ // slider flag rotate 0.000e+0 against 4.000e+1
3217
+ //
3218
+ // ⭐ The runtime repairs this for BONES and not for constraints, which is
3219
+ // why an author cannot reason it out from the bone case. `Slider.sort`
3220
+ // clears `sorted` on every bone its animation keys and re-sorts them, so
3221
+ // those bones always update after the slider; for a constraint it calls
3222
+ // `skeleton.constrained(constraints[t.constraintIndex])`, which only swaps
3223
+ // the pose the constraint will be read through and moves nothing in the
3224
+ // update cache.
3225
+ //
3226
+ // 🔒 Keying its OWN `mix` or `time` is the same failure with the indices
3227
+ // equal, and it is the one A37 cannot see: A37 asks whether any animation
3228
+ // keys the slider's `mix` and the slider's own animation is one of them, so
3229
+ // a slider muted at setup that keys its own `mix` up reports green and is
3230
+ // dead forever — `update` returns on `mix === 0` before the animation that
3231
+ // would raise it is ever applied. Only a slider can reach that shape: the
3232
+ // constraint at the driver's own index IS the driver.
3233
+ //
3234
+ // ⚠️ `physics` `reset` is NOT in this population, and the measurement is why
3235
+ // the card that asked for "every constraint a slider's animation keys" is
3236
+ // refused here on one spelling. `PhysicsConstraintResetTimeline` writes no
3237
+ // pose at all — `constraint.reset()` sets fields on the constraint object,
3238
+ // which `resetConstrained` does not touch — and it fires only when a frame
3239
+ // time is CROSSED. A slider applies its animation with `p.time` as both
3240
+ // `lastTime` and `time`, so nothing is ever crossed: [measured] 0 calls to
3241
+ // `PhysicsConstraint.reset` at 6 dial readings x 4 frames in BOTH array
3242
+ // orders, against 1 call when the same animation is applied over a span.
3243
+ // Refusing it would name a reorder that repairs nothing, so the SKIP says
3244
+ // what was found instead.
3245
+ check('A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER', () => {
3246
+ const sliders = data.constraints.filter((c) => c instanceof SliderData);
3247
+ if (!sliders.length) {
3248
+ return skip('A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER', 'the skeleton declares no slider constraint');
3249
+ }
3250
+ /**
3251
+ * The runtime class behind a `*Data`, and the word a motion spec writes
3252
+ * for its kind. Taken structurally rather than through
3253
+ * `ConstraintData<T, P>`, whose two type arguments the runtime itself
3254
+ * fills with `any` — which `src/` may not write.
3255
+ */
3256
+ const kindOf = (constraint: { constructor: { name: string } }): { word: string; runtime: string } => {
3257
+ const runtime = constraint.constructor.name.replace(/Data$/, '');
3258
+ return { word: runtime.replace(/Constraint$/, '').toLowerCase(), runtime };
3259
+ };
3260
+ /**
3261
+ * `physicsConstraintWind` -> `wind`, `pathConstraintMix` -> `mix`,
3262
+ * `sliderTime` -> `time`, `ikConstraint` -> `ik`: the `Property` name with
3263
+ * the constraint kind taken off the front, which is the word the motion
3264
+ * spec's own track or block carries. Derived rather than tabulated — a
3265
+ * table here would be one more hand-kept list of the runtime's enum.
3266
+ */
3267
+ const keyedWord = (property: string, kind: string): string => {
3268
+ const rest = property.replace(/^(ik|transform|path|physics|slider)(Constraint)?/, '');
3269
+ return rest === '' ? kind : rest.charAt(0).toLowerCase() + rest.slice(1);
3270
+ };
3271
+ /**
3272
+ * Which constraints one timeline writes into.
3273
+ *
3274
+ * A physics timeline whose animation names no constraint carries
3275
+ * `constraintIndex -1`, and `PhysicsConstraintTimeline.apply` reads that
3276
+ * as every ACTIVE physics constraint whose own data declares that property
3277
+ * global. No rig spec reaches it — the compiler refuses a track naming an
3278
+ * unknown physics constraint, the empty name among them — but a file rigc
3279
+ * did not write can carry one, and this is the only assertion that would
3280
+ * have to guess at it.
3281
+ */
3282
+ const drivenBy = (timeline: Timeline & ConstraintTimeline): number[] => {
3283
+ if (timeline.constraintIndex >= 0) return [timeline.constraintIndex];
3284
+ if (!(timeline instanceof PhysicsConstraintTimeline)) return [];
3285
+ return data.constraints.flatMap((one, index) => (one instanceof PhysicsConstraintData && timeline.global(one) ? [index] : []));
3286
+ };
3287
+ let pairs = 0;
3288
+ let resets = 0;
3289
+ for (const driver of sliders) {
3290
+ const driverIndex = data.constraints.indexOf(driver);
3291
+ for (const timeline of driver.animation?.timelines ?? []) {
3292
+ if (!isConstraintTimeline(timeline)) continue;
3293
+ if (timeline instanceof PhysicsConstraintResetTimeline) {
3294
+ resets++;
3295
+ continue;
3296
+ }
3297
+ const property = String(Property[Number(timeline.propertyIds[0].split('|')[0])] ?? timeline.propertyIds[0]);
3298
+ const everyPhysics = timeline.constraintIndex < 0;
3299
+ for (const drivenIndex of drivenBy(timeline)) {
3300
+ const driven = data.constraints[drivenIndex];
3301
+ if (driven === undefined) continue;
3302
+ pairs++;
3303
+ if (drivenIndex > driverIndex) continue;
3304
+ const kind = kindOf(driven);
3305
+ const word = keyedWord(property, kind.word);
3306
+ const animation = `animation "${driver.animation?.name}"`;
3307
+ const reference = `\`${kind.word}.${driven.name}${word === kind.word ? '' : `.${word}`}\``;
3308
+ fail(
3309
+ 'A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER',
3310
+ drivenIndex === driverIndex
3311
+ ? `slider "${driver.name}" (constraints[${driverIndex}]) keys its own \`${word}\` in ${animation}. ` +
3312
+ '`Slider.update` reads `appliedPose.' +
3313
+ `${word}\` as the ${word === 'mix' ? 'alpha it applies that animation with' : 'time it applies that animation at'}, ` +
3314
+ 'before the animation runs, so the key is written after its only reader and `Posed.resetConstrained` ' +
3315
+ `puts the pose back before the next frame${
3316
+ word === 'mix'
3317
+ ? ' — and at `mix` 0 `update` returns before applying anything at all, so the key that would raise it is unreachable'
3318
+ : ''
3319
+ }. Key ${reference} from a slider EARLIER in \`constraints\`, or state the ` +
3320
+ `\`${word}\` this slider should start at in the rig spec`
3321
+ : `slider "${driver.name}" (constraints[${driverIndex}]) keys ${
3322
+ word === kind.word ? `the \`${word}\` timeline` : `\`${word}\``
3323
+ } of ${kind.word} constraint ` +
3324
+ `"${driven.name}" (constraints[${drivenIndex}]) in ${animation}${
3325
+ everyPhysics ? ' — the timeline names no constraint, which the runtime reads as every physics constraint declaring that property global —' : ''
3326
+ }, and "${driven.name}" updates FIRST. The \`constraints\` array is the update order ` +
3327
+ '(`Skeleton.updateCache` walks it and each constraint\'s `sort` pushes itself as it is reached) and ' +
3328
+ `\`${kind.runtime}.update\` reads its own \`appliedPose\` before applying anything, so that key is ` +
3329
+ 'written after the only read of it and `Posed.resetConstrained` discards it before the next frame: ' +
3330
+ `what "${driven.name}" drives is dead at every reading of "${driver.name}"'s dial, although its pose ` +
3331
+ `still holds the number. Move "${driver.name}" before "${driven.name}" in \`constraints\`, or key ` +
3332
+ `${reference} from a slider that already is`,
3333
+ );
3334
+ }
3335
+ }
3336
+ }
3337
+ if (pairs === 0) {
3338
+ return skip(
3339
+ 'A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER',
3340
+ `no animation applied by one of the ${sliders.length} slider constraint${sliders.length === 1 ? '' : 's'} keys a ` +
3341
+ `property of a constraint, so no slider here drives a constraint${
3342
+ resets === 0
3343
+ ? ''
3344
+ : ` — the ${resets} \`physics\` \`reset\` key(s) they do carry are not a pose write, and [measured] a slider ` +
3345
+ 'applies its animation at one instant, so `PhysicsConstraintResetTimeline` never fires from one in either array order'
3346
+ }`,
3347
+ );
3348
+ }
3349
+ stats.sliderDrivenConstraints = pairs;
3350
+ });
3351
+
2969
3352
  // --- A38: a per-skin member list and its `skin: true` flag agree --------
2970
3353
  //
2971
3354
  // 🚨 Two halves of one switch, and either half alone is dead data in silence.