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/compile.ts CHANGED
@@ -761,6 +761,20 @@ interface ValueTrackShape {
761
761
  * The defaults matter more than they look: Spine omits a field that equals the
762
762
  * setup value, and `scale` defaults to 1 while `translate` defaults to 0. Emit
763
763
  * `x: 0` on a scale key and the bone collapses to nothing, silently.
764
+ *
765
+ * ⭐ **The table IS the dispatch**, the same way `SLOT_TRACKS` is: `resolveTargets`
766
+ * asks `property in BONE_TRACKS` to decide the family, `compileValueTrack` writes
767
+ * a key out of the shape it finds here, and the refusal for a property that is not
768
+ * in it prints `Object.keys` of the same object — so the ten an author is handed
769
+ * cannot disagree with the ten the emitter has, because there is only one list.
770
+ *
771
+ * 🚨 It printed nothing until issue #656. The dispatch was already this table, but
772
+ * a bone track whose property missed it was refused as *"bone X cannot take slot
773
+ * property Y"* — the wrong family for a spelling that usually belongs to none, and
774
+ * the one target family whose refusal offered no way forward. That is also why the
775
+ * selftest's spelling census had to STATE these ten (`PS143`, `PS144`): four
776
+ * families printed their own vocabulary in their refusals and the bone family
777
+ * printed nothing, so there was nothing to read it off.
764
778
  */
765
779
  const BONE_TRACKS: Record<string, ValueTrackShape> = {
766
780
  translate: { fields: ['x', 'y'], identity: [0, 0] },
@@ -853,6 +867,38 @@ const SLIDER_TRACKS: Record<string, ValueTrackShape> = {
853
867
  mix: { fields: ['value'], identity: [1] },
854
868
  };
855
869
 
870
+ /**
871
+ * Slot timelines (`animations.<a>.slots.<slot>.<timeline>`): the two
872
+ * `compileTrack` writes, and which key shape each one is written with.
873
+ *
874
+ * ⭐ **The table IS the dispatch.** `compileTrack` reads the shape out of here
875
+ * to pick its branch, and the refusal for a property that is not in it prints
876
+ * `Object.keys` of the same object — so the list an author is given cannot
877
+ * disagree with the list the emitter has, because there is only one.
878
+ *
879
+ * 🚨 It exists because the dispatch used to be one `if` on `attachment` and a
880
+ * fall-through to rgba, which made **every** other property name a legal
881
+ * spelling of an rgba timeline: `{"slot": "x", "property": "sequence", "v": [0,
882
+ * 0, 0, 0]}` compiled and wrote `slots.x.sequence` with rgba-shaped keys. The
883
+ * gate caught the file (`A00_ROUNDTRIP_PARSE: threw: Invalid timeline type for
884
+ * a slot`, and `A05_CURVE_ARRAY_LENGTH`) and the one-channel spelling of the
885
+ * same mistake was refused at compile as *"rgba value needs 4 channels, got
886
+ * 1"* — a message about a key nobody wrote. It is the shape `A21`'s
887
+ * `meshKinds[slot] || 'ring'` had (issue #44): a default that turns "nothing to
888
+ * emit" into an emission of the wrong thing (issue #650).
889
+ *
890
+ * ⚠️ The KEY is the timeline name as the file carries it, and the VALUE names
891
+ * the branch below that writes its keys — so an entry added here without a
892
+ * branch to write it is an entry emitted in some other timeline's shape, which
893
+ * is the defect this table closed rather than a new affordance. The format has
894
+ * four more (`rgb`, `alpha`, `rgba2`, `rgb2`); rigc emits none of them, and
895
+ * `A12_NO_DARK_COLOR` refuses the last two outright.
896
+ */
897
+ export const SLOT_TRACKS: Record<string, 'attachment' | 'rgba'> = {
898
+ attachment: 'attachment',
899
+ rgba: 'rgba',
900
+ };
901
+
856
902
  /**
857
903
  * The three constraint families a `MotionTrack` can target, and the table of
858
904
  * timelines each one accepts.
@@ -4686,6 +4732,42 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
4686
4732
  // time = to + (value - from) * 0, so the slider holds one frame forever.
4687
4733
  throw new CompileError(`${where}: scale is 0, so the bone's property cannot move the slider's time at all`);
4688
4734
  }
4735
+ // The window of driving values that can reach a frame at all, and the two
4736
+ // functions every figure in the two reader clauses below comes off.
4737
+ //
4738
+ // ⭐ **One copy, read by both readers** (issue #657). The circle owned
4739
+ // this arithmetic while it was the only reader with a bound; the world
4740
+ // `scale` readers have a floor at 0 and the same mapping carries them
4741
+ // there, so the second clause reads these numbers instead of computing its
4742
+ // own. That is the rule this clause has already paid for twice — #417
4743
+ // tested one end because its fixture only left the circle at that end, and
4744
+ // #431 wrapped by one subtraction because every fixture sat within a turn
4745
+ // — and a second copy with a sign edited is exactly the shape both took.
4746
+ const fromValue = spec.from === undefined ? 0 : needNumber(spec.from, 'from');
4747
+ const toTime = spec.to === undefined ? 0 : needNumber(spec.to, 'to');
4748
+ const perUnit = spec.scale === undefined ? 1 : needNumber(spec.scale, 'scale');
4749
+ const duration = ctx.animationDurations.get(animation) ?? 0;
4750
+ /** The driving value that maps to the animation's first frame, and to its last. */
4751
+ const atStart = fromValue - toTime / perUnit;
4752
+ const atEnd = fromValue + (duration - toTime) / perUnit;
4753
+ const lowest = Math.min(atStart, atEnd);
4754
+ const highest = Math.max(atStart, atEnd);
4755
+ /**
4756
+ * The time this mapping puts a driving value at, before the runtime
4757
+ * touches it — the one arithmetic every figure below comes off.
4758
+ *
4759
+ * ⚠️ **Computed, not asserted** (issue #423). The circle's clause used to
4760
+ * end *"— outside the animation's Ds. With `loop`: false that is
4761
+ * `Math.max(0, time)` holding the last frame; with `loop`: true it wraps
4762
+ * to some other frame"*, which states a consequence rather than measuring
4763
+ * one — and is flatly false for a range spanning a full turn, where the
4764
+ * wrapped reading lands INSIDE the animation. It also printed both loop
4765
+ * modes and left the reader to pick. A message that hedges is a message
4766
+ * that has not measured.
4767
+ */
4768
+ const timeAt = (value: number): number => toTime + (value - fromValue) * perUnit;
4769
+ /** That time clamped into the animation — the frame a pose actually holds. */
4770
+ const intoFrame = (time: number): number => Math.min(Math.max(time, 0), duration);
4689
4771
  // ⚠️ `local: false` reads the bone's WORLD rotation, and `FromRotate.value`
4690
4772
  // (`TransformConstraintData.js`) ends
4691
4773
  //
@@ -4741,15 +4823,6 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
4741
4823
  // or the whole turn 0°..360° — is how you write this axis under
4742
4824
  // `local: false` and it works.
4743
4825
  if (property === 'rotate' && spec.local !== true) {
4744
- const fromValue = spec.from === undefined ? 0 : needNumber(spec.from, 'from');
4745
- const toTime = spec.to === undefined ? 0 : needNumber(spec.to, 'to');
4746
- const perUnit = spec.scale === undefined ? 1 : needNumber(spec.scale, 'scale');
4747
- const duration = ctx.animationDurations.get(animation) ?? 0;
4748
- /** The driving value that maps to the animation's first frame, and to its last. */
4749
- const atStart = fromValue - toTime / perUnit;
4750
- const atEnd = fromValue + (duration - toTime) / perUnit;
4751
- const lowest = Math.min(atStart, atEnd);
4752
- const highest = Math.max(atStart, atEnd);
4753
4826
  // Which end of the circle the range leaves. One record, so the end, its
4754
4827
  // reading, the time it lands on and the two clauses that name the side
4755
4828
  // are derived once rather than twice.
@@ -4805,20 +4878,6 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
4805
4878
  * tracks the closed form to 3.3e-8s.
4806
4879
  */
4807
4880
  const readAs = ((dead.end % 360) + 360) % 360;
4808
- /**
4809
- * The time this mapping puts a driving value at, before the runtime
4810
- * touches it — the one arithmetic every figure below comes off.
4811
- *
4812
- * ⚠️ **Computed, not asserted** (issue #423). This clause used to end
4813
- * *"— outside the animation's Ds. With `loop`: false that is
4814
- * `Math.max(0, time)` holding the last frame; with `loop`: true it
4815
- * wraps to some other frame"*, which states a consequence rather than
4816
- * measuring one — and is flatly false for a range spanning a full
4817
- * turn, where the wrapped reading lands INSIDE the animation. It also
4818
- * printed both loop modes and left the reader to pick. A message that
4819
- * hedges is a message that has not measured.
4820
- */
4821
- const timeAt = (value: number): number => toTime + (value - fromValue) * perUnit;
4822
4881
  const lands = timeAt(readAs);
4823
4882
  // `[0, 360)` is the whole of what `FromRotate.value` returns (issue
4824
4883
  // #417), so the readings that reach the animation at all are that
@@ -4829,7 +4888,6 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
4829
4888
  const reachLo = Math.max(0, lowest);
4830
4889
  const reachHi = Math.min(360, highest);
4831
4890
  const reaches = reachLo <= reachHi;
4832
- const intoFrame = (time: number): number => Math.min(Math.max(time, 0), duration);
4833
4891
  const reachA = intoFrame(timeAt(reachLo));
4834
4892
  const reachB = intoFrame(timeAt(reachHi));
4835
4893
  const span = `${Math.min(reachA, reachB).toFixed(3)}s..${Math.max(reachA, reachB).toFixed(3)}s`;
@@ -4923,6 +4981,78 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
4923
4981
  );
4924
4982
  }
4925
4983
  }
4984
+ // ⚠️ `local: false` reads the bone's WORLD scale, and `FromScaleX.value`
4985
+ // / `FromScaleY.value` (`TransformConstraintData.js`) are
4986
+ //
4987
+ // const a = source.a / skeleton.scaleX, c = source.c / skeleton.scaleY;
4988
+ // return Math.sqrt(a * a + c * c) + offsets[TransformConstraintData.SCALEX];
4989
+ //
4990
+ // — a MAGNITUDE. The driven field is in both terms, so the reading is
4991
+ // `|value|` and `[0, ∞)` is the whole of what that reader can return. The
4992
+ // floor is REACHED rather than approached (a bone whose own scale, or
4993
+ // whose parent's, is 0 reads exactly 0), which is why a range whose bottom
4994
+ // is exactly 0 stays legal and only one that dips below it is refused.
4995
+ //
4996
+ // 🚨 **Below the floor the axis does not go dead, it FOLDS** (issue #657),
4997
+ // and that is why this is a second clause rather than the circle with
4998
+ // another property name in it. A reading the range cannot reach is one
4999
+ // frame pinned; a reading it reaches TWICE is two dial positions posing
5000
+ // the same face, so the message has to name the mirror rather than a dead
5001
+ // arc. Measured through spine-core on `from: 0, to: 0.5, scale: 0.25` over
5002
+ // a 1 s animation — driving window −2..+2 — the applied time at −2.000,
5003
+ // −1.500, −1.000 and −0.500 is 1.000000s, 0.875000s, 0.750000s and
5004
+ // 0.625000s: the same six decimals the dial at +2.000, +1.500, +1.000 and
5005
+ // +0.500 applies, with the posed bone matching to the digit. The same rig
5006
+ // read `local: true` sweeps −2 → +2 monotonically from 0.000000s, which is
5007
+ // what makes that repair worth naming.
5008
+ //
5009
+ // ⭐ **No loop branch, and that is measured rather than economised.** The
5010
+ // circle's consequence turns on `Slider.loop` because a held frame is held
5011
+ // only under `Math.max(0, time)`; a fold is not a clamp, so `loop: true`
5012
+ // cannot undo it — the same ±1.500 pair applies 1.875000s either way.
5013
+ if ((property === 'scaleX' || property === 'scaleY') && spec.local !== true && lowest < -SLIDER_WRAP_SLACK) {
5014
+ /** What the reader returns for a bone parked at the bottom of the range: the magnitude. */
5015
+ const readAs = Math.abs(lowest);
5016
+ const lands = timeAt(readAs);
5017
+ // The readings that reach the animation are `[0, ∞)` met with the
5018
+ // driving window — the circle's `reachLo`/`reachHi` with the ceiling
5019
+ // taken out, off the same two numbers the message has already printed.
5020
+ const reaches = highest >= 0;
5021
+ /**
5022
+ * How much of the range lies below the floor: the WIDTH of
5023
+ * `[lowest, highest]` under 0, not the distance to its far end.
5024
+ *
5025
+ * 🚨 `-lowest` is that distance, and the two are equal only while the
5026
+ * range STRADDLES the floor — the same trap issue #434 paid for one
5027
+ * reader over, where a range lying wholly outside was told a width wider
5028
+ * than itself. A `-6..-2` window is 4.000 below the floor and `-lowest`
5029
+ * would print 6.000, which is `readAs` again with a different name on it.
5030
+ */
5031
+ const below = Math.min(0, highest) - lowest;
5032
+ // The arc an author writes twice: the part of the range above 0 that the
5033
+ // part below 0 mirrors onto. Both ends are inside `[lowest, highest]` —
5034
+ // 0 because the range straddles it and this is the `reaches` branch, the
5035
+ // top because it is `highest` or less — so both map INTO the animation
5036
+ // and neither needs clamping.
5037
+ const mirrorTop = Math.min(highest, -lowest);
5038
+ const mirrorA = timeAt(0);
5039
+ const mirrorB = timeAt(mirrorTop);
5040
+ const consequence = reaches
5041
+ ? `${below.toFixed(3)} of the range below 0 repeats 0.000..${mirrorTop.toFixed(3)}, which is ` +
5042
+ `${Math.min(mirrorA, mirrorB).toFixed(3)}s..${Math.max(mirrorA, mirrorB).toFixed(3)}s of the animation, in reverse.`
5043
+ : `the whole ${below.toFixed(3)} of this range is below 0, so it reaches none of the animation's ${duration}s — ` +
5044
+ `every value the reader can return maps past it and the pose holds the frame at ${intoFrame(timeAt(0)).toFixed(3)}s.`;
5045
+ throw new CompileError(
5046
+ `${where}: drives off bone "${String(spec.bone)}" ${property} with "local": false, and the driving values ` +
5047
+ `that reach animation "${animation}" (0s..${duration}s) run from ${lowest.toFixed(3)} to ${highest.toFixed(3)}. ` +
5048
+ `A world scale is read through \`From${property === 'scaleX' ? 'ScaleX' : 'ScaleY'}.value\` as ` +
5049
+ `\`Math.sqrt(${property === 'scaleX' ? 'a² + c²' : 'b² + d²'})\`, a magnitude, so the bone at ` +
5050
+ `${lowest.toFixed(3)} is read as ${readAs.toFixed(3)} and maps to time ${lands.toFixed(3)}s — the time the ` +
5051
+ `bone at ${readAs.toFixed(3)} maps to. Positions below 0 read as the mirror of positions above it: ` +
5052
+ `${consequence} Nothing at runtime reports it. Add \`"local": true\` to read the bone's own scale signed and ` +
5053
+ 'unfolded — that is the form a squash axis wants — or move the range so it does not dip below 0.',
5054
+ );
5055
+ }
4926
5056
  for (const field of timeSide) {
4927
5057
  if (spec[field] !== undefined) {
4928
5058
  throw new CompileError(
@@ -6371,18 +6501,59 @@ function resolveTargets(track: MotionTrack, motion: MotionSpec, animName: string
6371
6501
  throw new CompileError(`animation "${animName}": "${track.property}" is a bone track but no bone is named`);
6372
6502
  }
6373
6503
  if (!isBoneTrack && track.bone) {
6374
- throw new CompileError(`animation "${animName}": bone "${track.bone}" cannot take slot property "${track.property}"`);
6504
+ // The bone family's sibling of `compileTrack`'s slot refusal, raised here for
6505
+ // the same reason it is raised there: before any key is shaped, and printed
6506
+ // from the dispatch table itself. What it replaces named the SLOT family for
6507
+ // a property that is usually in no family at all, and enumerated nothing
6508
+ // (issue #656). The tail clause is the one case where the old sentence was
6509
+ // true — `rgba` and `attachment` really are slot timelines — so the redirect
6510
+ // survives as a clause instead of as the whole message, and it is read off
6511
+ // `SLOT_TRACKS` rather than spelled again. A constraint property never
6512
+ // reaches here: `owning` above refuses it with the field that carries it.
6513
+ throw new CompileError(
6514
+ `animation "${animName}" bone "${track.bone}" has no timeline "${track.property}" ` +
6515
+ `(it has: ${Object.keys(BONE_TRACKS).join(', ')})` +
6516
+ (track.property in SLOT_TRACKS ? `. "${track.property}" is a slot timeline — put the name in "slot"` : ''),
6517
+ );
6375
6518
  }
6376
6519
  if (track.bone) return [track.bone];
6377
6520
  if (track.slot) return [track.slot];
6378
6521
  if (track.group) {
6379
- // A group's members are bones or slots depending on the property, which is
6380
- // what lets `stagger` express the ring lag: four grips, one track, a few
6381
- // frames apart. Plan 02 section 4-2 calls that lag the real detail of the
6382
- // stroke, and it is the difference between a ring following the part and two
6383
- // objects moving together (which reads as a composite).
6522
+ // A group's members are bones, slots or physics constraints depending on the
6523
+ // property, which is what lets `stagger` express a lag across them: four
6524
+ // members, one track, a few frames apart — the difference between a ring
6525
+ // following the part and two objects moving together, which reads as a
6526
+ // composite.
6384
6527
  const members = motion.groups?.[track.group];
6385
6528
  if (!members) throw new CompileError(`animation "${animName}": unknown group "${track.group}"`);
6529
+ // 🚨 The group is the one target shape whose FAMILY is decided by the
6530
+ // property, and three tables decide it: `constraintFamilyOf` asks
6531
+ // `property in PHYSICS_TRACKS`, this function asks `property in BONE_TRACKS`,
6532
+ // and what neither claims falls through to the slot branch. So a property in
6533
+ // no table left the dispatch with no family at all and the track was read as
6534
+ // a slot track — the first member was then refused for not being a slot
6535
+ // (`animation "A" targets unknown slot "rim_grip_a"` on a group of bones and
6536
+ // a misspelled bone property), or, when the members really were slots, with
6537
+ // the slot table alone on a family nothing had determined (issue #661).
6538
+ //
6539
+ // Raised AFTER the group's own existence check, because "group G has no
6540
+ // timeline P" would otherwise assert a group the file does not declare, and
6541
+ // BEFORE the members are resolved against the rig or any key is shaped: the
6542
+ // property is what the dispatch reads first, so it is what the refusal is
6543
+ // about. All three lists come from the objects the dispatch reads, so none
6544
+ // of them can drift from the emitter that owns it. A property that IS in
6545
+ // `PHYSICS_TRACKS` never reaches this line — `constraintFamilyOf` returns
6546
+ // `physics` for a group track that names one, and the family branch above
6547
+ // has already returned — and a path or slider property is refused further up
6548
+ // with the field its constraint's name goes in.
6549
+ if (!(track.property in BONE_TRACKS) && !(track.property in SLOT_TRACKS)) {
6550
+ throw new CompileError(
6551
+ `animation "${animName}" group "${track.group}" has no timeline "${track.property}" ` +
6552
+ `(a bone group has: ${Object.keys(BONE_TRACKS).join(', ')}; ` +
6553
+ `a slot group has: ${Object.keys(SLOT_TRACKS).join(', ')}; ` +
6554
+ `a ${CONSTRAINT_TRACK_FAMILIES.physics.label} group has: ${Object.keys(PHYSICS_TRACKS).join(', ')})`,
6555
+ );
6556
+ }
6386
6557
  return members;
6387
6558
  }
6388
6559
  throw new CompileError(`animation "${animName}": a track targets neither slot nor group`);
@@ -6601,6 +6772,16 @@ function compileTrack(
6601
6772
  skinAttachments: Record<string, Record<string, SpineAttachment>>,
6602
6773
  ): SpineTimelineKey[] {
6603
6774
  const where = `animation "${animName}" slot "${target}" ${track.property}`;
6775
+ // Before any key is shaped, and before the empty-track refusal: a property
6776
+ // the emitter has no branch for is the fault, and a track that names one has
6777
+ // no shape to be missing keys from (issue #650).
6778
+ const shape = SLOT_TRACKS[track.property];
6779
+ if (shape === undefined) {
6780
+ throw new CompileError(
6781
+ `animation "${animName}" slot "${target}" has no timeline "${track.property}" ` +
6782
+ `(it has: ${Object.keys(SLOT_TRACKS).join(', ')})`,
6783
+ );
6784
+ }
6604
6785
  if (!track.keys.length) throw new CompileError(`${where}: no keys`);
6605
6786
 
6606
6787
  const out: SpineTimelineKey[] = [];
@@ -6613,7 +6794,7 @@ function compileTrack(
6613
6794
  }
6614
6795
  checkKeyTime(where, time, key.t, duration);
6615
6796
 
6616
- if (track.property === 'attachment') {
6797
+ if (shape === 'attachment') {
6617
6798
  if (key.v !== null && typeof key.v !== 'string') {
6618
6799
  throw new CompileError(`${where}: attachment key value must be a string or null`);
6619
6800
  }
@@ -6626,7 +6807,8 @@ function compileTrack(
6626
6807
  continue;
6627
6808
  }
6628
6809
 
6629
- // rgba
6810
+ // rgba — the other shape `SLOT_TRACKS` names, and now the only way to reach
6811
+ // this branch: a property the table does not carry was refused above.
6630
6812
  if (!Array.isArray(key.v)) throw new CompileError(`${where}: rgba key value must be [r,g,b,a]`);
6631
6813
  const entry: SpineTimelineKey = { time, color: rgbaHex(key.v) };
6632
6814
  if (key.ease !== undefined && key.curve !== undefined) {
package/src/ingest.ts CHANGED
@@ -44,7 +44,7 @@
44
44
  * break `A18_DETERMINISTIC_EMIT` the first time anybody rebuilt from an ingested
45
45
  * spec.
46
46
  */
47
- import { SPINE_VERSION } from './compile.ts';
47
+ import { SLOT_TRACKS as EMITTED_SLOT_TRACKS, SPINE_VERSION } from './compile.ts';
48
48
  import { MOTION_SPEC_VERSION, parseMotionSpec } from './motion.ts';
49
49
  import { parseRigSpec, RIG_KEYS, RIG_SPEC_VERSION, type RigSpec } from './rig.ts';
50
50
  import type { MotionSpec } from './types.ts';
@@ -347,8 +347,18 @@ const HEADER_REDERIVED = ['spine'];
347
347
  /** The attachment types this module inverts. Everything else is refused by name. */
348
348
  const ATTACHMENT_TYPES = ['region', 'mesh', 'boundingbox', 'clipping', 'path'];
349
349
 
350
- /** The two slot timelines the motion spec carries (`compileTrack`'s two branches). */
351
- const SLOT_TRACKS = ['rgba', 'attachment'];
350
+ /**
351
+ * The slot timelines the motion spec carries — `compileTrack`'s own table,
352
+ * rather than a second list of the same two names.
353
+ *
354
+ * ⚠️ It was that second list until issue #650, spelled `['rgba', 'attachment']`
355
+ * with a comment saying where it had been copied from. The copy was true, which
356
+ * is the point: the emitter had no list at all — one `if` and a fall-through —
357
+ * so this module's blocker was the only place in `src/` that said what a slot
358
+ * track may be, and it said it about a compiler that accepted anything. Now
359
+ * there is one list and both sides read it.
360
+ */
361
+ const SLOT_TRACKS = Object.keys(EMITTED_SLOT_TRACKS);
352
362
 
353
363
  /**
354
364
  * Everything this module has a branch for, as the branches themselves state it.