spine-rigc 0.25.3 → 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/README.md +4 -4
- package/docs/AUTHORING.md +227 -24
- package/docs/FACE.md +97 -7
- package/package.json +1 -1
- package/src/compile.ts +169 -30
- package/src/validate.ts +403 -20
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] },
|
|
@@ -4718,6 +4732,42 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
4718
4732
|
// time = to + (value - from) * 0, so the slider holds one frame forever.
|
|
4719
4733
|
throw new CompileError(`${where}: scale is 0, so the bone's property cannot move the slider's time at all`);
|
|
4720
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);
|
|
4721
4771
|
// ⚠️ `local: false` reads the bone's WORLD rotation, and `FromRotate.value`
|
|
4722
4772
|
// (`TransformConstraintData.js`) ends
|
|
4723
4773
|
//
|
|
@@ -4773,15 +4823,6 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
4773
4823
|
// or the whole turn 0°..360° — is how you write this axis under
|
|
4774
4824
|
// `local: false` and it works.
|
|
4775
4825
|
if (property === 'rotate' && spec.local !== true) {
|
|
4776
|
-
const fromValue = spec.from === undefined ? 0 : needNumber(spec.from, 'from');
|
|
4777
|
-
const toTime = spec.to === undefined ? 0 : needNumber(spec.to, 'to');
|
|
4778
|
-
const perUnit = spec.scale === undefined ? 1 : needNumber(spec.scale, 'scale');
|
|
4779
|
-
const duration = ctx.animationDurations.get(animation) ?? 0;
|
|
4780
|
-
/** The driving value that maps to the animation's first frame, and to its last. */
|
|
4781
|
-
const atStart = fromValue - toTime / perUnit;
|
|
4782
|
-
const atEnd = fromValue + (duration - toTime) / perUnit;
|
|
4783
|
-
const lowest = Math.min(atStart, atEnd);
|
|
4784
|
-
const highest = Math.max(atStart, atEnd);
|
|
4785
4826
|
// Which end of the circle the range leaves. One record, so the end, its
|
|
4786
4827
|
// reading, the time it lands on and the two clauses that name the side
|
|
4787
4828
|
// are derived once rather than twice.
|
|
@@ -4837,20 +4878,6 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
4837
4878
|
* tracks the closed form to 3.3e-8s.
|
|
4838
4879
|
*/
|
|
4839
4880
|
const readAs = ((dead.end % 360) + 360) % 360;
|
|
4840
|
-
/**
|
|
4841
|
-
* The time this mapping puts a driving value at, before the runtime
|
|
4842
|
-
* touches it — the one arithmetic every figure below comes off.
|
|
4843
|
-
*
|
|
4844
|
-
* ⚠️ **Computed, not asserted** (issue #423). This clause used to end
|
|
4845
|
-
* *"— outside the animation's Ds. With `loop`: false that is
|
|
4846
|
-
* `Math.max(0, time)` holding the last frame; with `loop`: true it
|
|
4847
|
-
* wraps to some other frame"*, which states a consequence rather than
|
|
4848
|
-
* measuring one — and is flatly false for a range spanning a full
|
|
4849
|
-
* turn, where the wrapped reading lands INSIDE the animation. It also
|
|
4850
|
-
* printed both loop modes and left the reader to pick. A message that
|
|
4851
|
-
* hedges is a message that has not measured.
|
|
4852
|
-
*/
|
|
4853
|
-
const timeAt = (value: number): number => toTime + (value - fromValue) * perUnit;
|
|
4854
4881
|
const lands = timeAt(readAs);
|
|
4855
4882
|
// `[0, 360)` is the whole of what `FromRotate.value` returns (issue
|
|
4856
4883
|
// #417), so the readings that reach the animation at all are that
|
|
@@ -4861,7 +4888,6 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
4861
4888
|
const reachLo = Math.max(0, lowest);
|
|
4862
4889
|
const reachHi = Math.min(360, highest);
|
|
4863
4890
|
const reaches = reachLo <= reachHi;
|
|
4864
|
-
const intoFrame = (time: number): number => Math.min(Math.max(time, 0), duration);
|
|
4865
4891
|
const reachA = intoFrame(timeAt(reachLo));
|
|
4866
4892
|
const reachB = intoFrame(timeAt(reachHi));
|
|
4867
4893
|
const span = `${Math.min(reachA, reachB).toFixed(3)}s..${Math.max(reachA, reachB).toFixed(3)}s`;
|
|
@@ -4955,6 +4981,78 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
4955
4981
|
);
|
|
4956
4982
|
}
|
|
4957
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
|
+
}
|
|
4958
5056
|
for (const field of timeSide) {
|
|
4959
5057
|
if (spec[field] !== undefined) {
|
|
4960
5058
|
throw new CompileError(
|
|
@@ -6403,18 +6501,59 @@ function resolveTargets(track: MotionTrack, motion: MotionSpec, animName: string
|
|
|
6403
6501
|
throw new CompileError(`animation "${animName}": "${track.property}" is a bone track but no bone is named`);
|
|
6404
6502
|
}
|
|
6405
6503
|
if (!isBoneTrack && track.bone) {
|
|
6406
|
-
|
|
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
|
+
);
|
|
6407
6518
|
}
|
|
6408
6519
|
if (track.bone) return [track.bone];
|
|
6409
6520
|
if (track.slot) return [track.slot];
|
|
6410
6521
|
if (track.group) {
|
|
6411
|
-
// A group's members are bones or
|
|
6412
|
-
// what lets `stagger` express
|
|
6413
|
-
//
|
|
6414
|
-
//
|
|
6415
|
-
//
|
|
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.
|
|
6416
6527
|
const members = motion.groups?.[track.group];
|
|
6417
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
|
+
}
|
|
6418
6557
|
return members;
|
|
6419
6558
|
}
|
|
6420
6559
|
throw new CompileError(`animation "${animName}": a track targets neither slot nor group`);
|