spine-rigc 2.54.0 → 2.56.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.54.0",
3
+ "version": "2.56.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": {
@@ -0,0 +1,190 @@
1
+ /**
2
+ * How far one bone turns against another over an animation — the reading behind
3
+ * `rigc render --angle-range <bone>:<relative-to>[,…]` (issue #1414).
4
+ *
5
+ * ⭐ **A reading, not a gate**, as `--folds` and `--pass-through` are. A physics
6
+ * value has no scale an author can see: §4.6 lists `inertia`, `strength` and the
7
+ * rest with their bounds, and nothing said what `inertia` 0.5 does to a 54-unit
8
+ * chain under a hip swing — the first try on a real rig was four times too big.
9
+ * The answer is a number the pose already holds: how far the link turned against
10
+ * the bone it hangs from. So this reads it off the frames `render` draws and
11
+ * decides nothing.
12
+ *
13
+ * ## What the range is
14
+ *
15
+ * At each frame the bones are posed by whichever poser draws the frames, and each
16
+ * bone's **world direction** is that poser's own `rotationX` — the direction its x
17
+ * axis points, degrees counter-clockwise, y up (`BoneSnapshot`), never a second
18
+ * opinion recomputed from the matrix here. The relative angle is
19
+ *
20
+ * rotationX(bone) − rotationX(relative-to)
21
+ *
22
+ * taken into (−180, 180] on the first frame read, and then **followed**: each
23
+ * later frame's difference is moved by whole turns to within half a turn of the
24
+ * frame before it. So a link that sweeps 200° reads 200°, not −160°, and one that
25
+ * sways through ±180° does not read a jump of a whole turn. Following assumes no
26
+ * two consecutive frames are half a turn or more apart, which a frame grid that
27
+ * coarse could not show anyway.
28
+ *
29
+ * ⚠️ It is the angle **drawn**, not the bone's local `rotation`. The two agree for
30
+ * a child of the other bone when the parent carries no non-uniform scale or shear;
31
+ * otherwise they differ, and the drawn one is what a viewer sees.
32
+ *
33
+ * The range is over the frames on the render's own grid — `--fps`, from t = 0 to
34
+ * the animation's last frame — so a peak between two frames is not read. A bone
35
+ * the posed skin leaves unposed, or one scaled to nothing, has no direction: its
36
+ * world matrix is zero, and the reading is refused naming the bone and the frame
37
+ * rather than read as 0°.
38
+ *
39
+ * Pure: no clock, no randomness, no runtime. The bone snapshots are handed in by
40
+ * whichever poser drew the frames.
41
+ */
42
+ import type { BoneSnapshot } from './render_shared.ts';
43
+
44
+ /** One pair as the flag names it: the bone whose angle is read, and the bone it is read against. */
45
+ export interface BonePair {
46
+ bone: string;
47
+ relativeTo: string;
48
+ }
49
+
50
+ /** A malformed `--angle-range` value, or a pose the reading has no angle for — said by name. */
51
+ export class AngleRangeError extends Error {}
52
+
53
+ /**
54
+ * `--angle-range`'s value — `<bone>:<relative-to>[,<bone>:<relative-to>…]` —
55
+ * read against the skeleton's bones, or refused naming the entry: an empty
56
+ * entry, one without exactly one `:`, an empty name, a bone the skeleton does not
57
+ * have (with the bones it does), a bone read against itself (always 0°), and a
58
+ * pair named twice.
59
+ */
60
+ export function readBonePairs(raw: string, bones: readonly string[]): BonePair[] {
61
+ const form = '<bone>:<relative-to>[,<bone>:<relative-to>…]';
62
+ const pairs: BonePair[] = [];
63
+ const entries = raw.split(',');
64
+ for (let i = 0; i < entries.length; i++) {
65
+ const entry = entries[i].trim();
66
+ if (entry === '') throw new AngleRangeError(`--angle-range ${JSON.stringify(raw)}: entry ${i} is empty — it takes ${form}`);
67
+ const parts = entry.split(':');
68
+ if (parts.length !== 2) {
69
+ throw new AngleRangeError(`--angle-range: entry ${i} ${JSON.stringify(entry)} has ${parts.length - 1} ":" — it takes ${form}, one ":" per pair`);
70
+ }
71
+ const [bone, relativeTo] = parts.map((p) => p.trim());
72
+ for (const [role, name] of [['bone', bone], ['relative-to bone', relativeTo]] as const) {
73
+ if (name === '') throw new AngleRangeError(`--angle-range: entry ${i} ${JSON.stringify(entry)} names no ${role} — it takes ${form}`);
74
+ if (!bones.includes(name)) {
75
+ throw new AngleRangeError(`--angle-range: entry ${i} names ${role} ${JSON.stringify(name)}, which this skeleton does not have; its bones are [${bones.join(', ')}]`);
76
+ }
77
+ }
78
+ if (bone === relativeTo) {
79
+ throw new AngleRangeError(`--angle-range: entry ${i} reads bone ${JSON.stringify(bone)} against itself, which is 0° on every frame — name a second bone`);
80
+ }
81
+ const again = pairs.findIndex((p) => p.bone === bone && p.relativeTo === relativeTo);
82
+ if (again >= 0) throw new AngleRangeError(`--angle-range: entry ${i} names "${bone}:${relativeTo}" again — entry ${again} already reads it`);
83
+ pairs.push({ bone, relativeTo });
84
+ }
85
+ return pairs;
86
+ }
87
+
88
+ /** One sampled frame: its index, its time, and every bone's world transform. */
89
+ export interface AngleFrame {
90
+ index: number;
91
+ time: number;
92
+ bones: readonly BoneSnapshot[];
93
+ }
94
+
95
+ /** One extreme of a range: the angle, degrees, and the frame that reached it first. */
96
+ export interface AngleExtreme {
97
+ degrees: number;
98
+ index: number;
99
+ time: number;
100
+ }
101
+
102
+ /** One pair's reading over one set. */
103
+ export interface AngleRangeReading {
104
+ bone: string;
105
+ relativeTo: string;
106
+ /** Frames read — every frame of the set. */
107
+ frames: number;
108
+ low: AngleExtreme;
109
+ high: AngleExtreme;
110
+ }
111
+
112
+ /**
113
+ * Wrong readings the suite plants to see its controls go red (issue #1414):
114
+ * `worldAngle` reads the bone's own world direction and ignores the bone it is
115
+ * read against; `noFollow` leaves each frame's difference in (−180, 180] rather
116
+ * than following it from the frame before.
117
+ */
118
+ export interface AnglePlant {
119
+ worldAngle?: true;
120
+ noFollow?: true;
121
+ }
122
+
123
+ /** `d` taken into (−180, 180]. */
124
+ function wrapped(d: number): number {
125
+ let r = d % 360;
126
+ if (r <= -180) r += 360;
127
+ if (r > 180) r -= 360;
128
+ return r;
129
+ }
130
+
131
+ /** A world matrix with no direction — every entry zero: an unposed bone, or one scaled to nothing. */
132
+ function directionless(b: BoneSnapshot): boolean {
133
+ return b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0;
134
+ }
135
+
136
+ /**
137
+ * Each pair's range over `frames`, in the pairs' order — refused by
138
+ * `AngleRangeError`, naming the bone and the frame, where a bone has no
139
+ * direction (`directionless`), and naming the bone where a frame carries no
140
+ * snapshot of it at all (frames sampled without `{ bones: true }`).
141
+ */
142
+ export function angleRangesOf(pairs: readonly BonePair[], frames: readonly AngleFrame[], where: string, plant: AnglePlant = {}): AngleRangeReading[] {
143
+ return pairs.map(({ bone, relativeTo }) => {
144
+ let previous: number | null = null;
145
+ let low: AngleExtreme | null = null;
146
+ let high: AngleExtreme | null = null;
147
+ for (const frame of frames) {
148
+ const at = (name: string): BoneSnapshot => {
149
+ const found = frame.bones.find((b) => b.name === name);
150
+ if (found === undefined) throw new AngleRangeError(`--angle-range, ${where} frame ${frame.index}: no world transform for bone ${JSON.stringify(name)} was sampled`);
151
+ if (directionless(found)) {
152
+ throw new AngleRangeError(
153
+ `--angle-range, ${where} frame ${frame.index} (t=${frame.time.toFixed(4)}s): bone ${JSON.stringify(name)} has no direction — its world matrix is zero ` +
154
+ '(a bone the posed skin leaves unposed, or one scaled to nothing), so it has no angle to read',
155
+ );
156
+ }
157
+ return found;
158
+ };
159
+ const a = at(bone);
160
+ const b = at(relativeTo);
161
+ const difference = plant.worldAngle === true ? a.rotationX : a.rotationX - b.rotationX;
162
+ let angle = wrapped(difference);
163
+ if (previous !== null && plant.noFollow !== true) angle += 360 * Math.round((previous - angle) / 360);
164
+ previous = angle;
165
+ if (low === null || angle < low.degrees) low = { degrees: angle, index: frame.index, time: frame.time };
166
+ if (high === null || angle > high.degrees) high = { degrees: angle, index: frame.index, time: frame.time };
167
+ }
168
+ if (low === null || high === null) throw new AngleRangeError(`--angle-range, ${where}: no frame was sampled`);
169
+ return { bone, relativeTo, frames: frames.length, low, high };
170
+ });
171
+ }
172
+
173
+ /** Degrees to four places, with a rounding to zero printed as `0.0000` rather than `-0.0000`. */
174
+ export function degreesText(d: number): string {
175
+ const text = d.toFixed(4);
176
+ return text === '-0.0000' ? '0.0000' : text;
177
+ }
178
+
179
+ /**
180
+ * The printed line: the set, the pair, the low and the high with the frame each
181
+ * was first reached on, the span, and the grid it was read on.
182
+ */
183
+ export function angleRangeLine(animation: string | null, fps: number, r: AngleRangeReading): string {
184
+ const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
185
+ return (
186
+ `${where}: bone ${JSON.stringify(r.bone)} against ${JSON.stringify(r.relativeTo)} ranges ${degreesText(r.low.degrees)}° to ${degreesText(r.high.degrees)}° ` +
187
+ `(a span of ${degreesText(r.high.degrees - r.low.degrees)}°) over ${r.frames} frame(s) at ${fps} fps; ` +
188
+ `lowest at frame ${r.low.index} (t=${r.low.time.toFixed(4)}s), highest at frame ${r.high.index} (t=${r.high.time.toFixed(4)}s)`
189
+ );
190
+ }
@@ -74,7 +74,8 @@ const SETUP_POSE_SAYS: Record<string, (pose: PhysicsSetup, animations: number, r
74
74
  // on the arm's own words, which `T100` holds.
75
75
  strength: (pose, _animations, rule) => `has strength ${pose.strength}; ${setupBasisSays(rule, pose.strength)}`,
76
76
  // The bound is read off the row, so the setup pose and a key cannot state two
77
- // intervals; the reason is the arm the value took, since the ends are inside (#794).
77
+ // intervals; the reason is the arm the value took — above 1, at 1 (issue
78
+ // #1422, inside from #794 until then) or below 0.
78
79
  damping: (pose, _animations, rule) =>
79
80
  `has damping ${pose.damping}; must be ${rule.states} — the per-step decay is \`damping ** (60 * step)\`, and ` +
80
81
  setupBasisSays(rule, pose.damping),
@@ -92,10 +93,11 @@ function setupBasisSays(rule: PhysicsPoseRule, poseValue: number): string {
92
93
 
93
94
  // Every failure mode here is silent. The five component fields default to
94
95
  // 0, so a constraint can drive nothing at all; `mix` 0 mutes it; `mass` 0
95
- // becomes an infinite massInverse; and `damping` above 1 never settles, which
96
- // on a mesh-driving bone means the canvas re-rasterises forever. 1 itself is
97
- // inside the bound since issue #794: it never decays, which is finite and a
98
- // choice, and what it costs a consumer's frame is the consumer's to judge.
96
+ // becomes an infinite massInverse; and `damping` at or above 1 never settles,
97
+ // which on a mesh-driving bone means the canvas re-rasterises forever. 1 itself
98
+ // was inside the bound from issue #794 to #1422, as a jiggle held rather than a
99
+ // fault; it is out because a decay of 1 puts the step's eigenvalues on the unit
100
+ // circle, and a chain of such links was measured resonating without limit.
99
101
  //
100
102
  // 🔑 **Two arms, one criterion.** The second arm below reads every physics
101
103
  // TIMELINE key, and it is written against `PHYSICS_POSE_RULES` — the table
@@ -410,12 +412,72 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
410
412
  if (combo.strength.value === setup.strength.value && combo.massInverse.value === setup.massInverse.value && combo.damping.value === setup.damping.value) continue;
411
413
  const at = physicsStepStability(combo.strength.value, combo.massInverse.value, combo.damping.value, physics.step);
412
414
  if (at.stable) continue;
415
+ // A decay rounded to 1 by the setup damping itself is the setup line's, whatever else this animation keys.
416
+ if (!(at.d < 1) && at.k < at.bound && combo.damping.from === 'setup') continue;
413
417
  fail(
414
418
  'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
415
419
  `animation "${animation}" can pose physics "${facts.constraints[index]?.name ?? `#${index}`}" at ${runawaySays(combo, physics.step, at)}`,
416
420
  );
417
421
  }
418
422
  }
423
+
424
+ // --- strength held at 0 for as long as an animation plays (issue #1422) ---
425
+ //
426
+ // A strength KEY of 0 is accepted (issue #727) because it releases the
427
+ // constraint for a span and a later key pulls the offset back. An animation
428
+ // whose strength timelines pose 0 at EVERY time has no later key: [measured on
429
+ // #1422, x drive, the runtime's default world, spine-core and rigc's core
430
+ // identical bit for bit] the offset grows exactly linearly while it plays —
431
+ // 1.33e4 at 60 s, 5.33e4 at 240 s, 1.33e5 at 600 s — and A10's walk of
432
+ // max(duration, 1) s sees a finite number and passes it.
433
+ //
434
+ // 🔑 **"Every time it plays", not "looped".** Whether an animation loops is
435
+ // not in the artifact: skeleton JSON has no loop flag, the motion spec's
436
+ // `loop` is a player hint the compiler never reads (`MotionAnimation.loop`),
437
+ // and an editor export carries neither — and when it plays, and for how long,
438
+ // is the consumer's. The criterion that needs none of that is the one the
439
+ // runtime makes true either way: looped, the animation never leaves its
440
+ // values; played once, a track holds its last frame, which is 0. So the
441
+ // clause reads the timeline alone — every key and every Bézier sample, as
442
+ // the pose field it lands in, and the first key at t = 0, since before a
443
+ // later first key the timeline poses the setup value, which the strength row
444
+ // holds above 0.
445
+ //
446
+ // Per animation and constraint: when two strength timelines reach one
447
+ // constraint in one animation (a named one and the global one), it is held
448
+ // only if both are — a timeline that lifts it is a later key for the other.
449
+ // A constraint driving nothing is named by the first clause, and one resting
450
+ // at strength 0 or below by its row, so neither is read here.
451
+ const strengthRule = physicsRuleFor('strength');
452
+ const heldAtZero = new Map<string, Map<number, { keys: number; held: boolean }>>();
453
+ for (const timeline of facts.timelines) {
454
+ if (timeline.kind !== 'physics' || timeline.word !== 'strength') continue;
455
+ const first = timeline.frames[0];
456
+ const posed = (value: number): number => (timeline.posed === undefined ? value : timeline.posed(value));
457
+ const held = first !== undefined && first.time <= 0 && timeline.channelValues(0).every((value) => posed(value) === 0);
458
+ const byConstraint = heldAtZero.get(timeline.animation) ?? new Map<number, { keys: number; held: boolean }>();
459
+ heldAtZero.set(timeline.animation, byConstraint);
460
+ for (const index of timeline.reach) {
461
+ const was = byConstraint.get(index);
462
+ byConstraint.set(index, { keys: (was?.keys ?? 0) + timeline.frames.length, held: (was?.held ?? true) && held });
463
+ }
464
+ }
465
+ for (const [animation, byConstraint] of heldAtZero) {
466
+ for (const [index, { keys, held }] of byConstraint) {
467
+ const constraint = facts.constraints[index];
468
+ const physics = constraint?.physics;
469
+ if (!held || constraint === undefined || physics === undefined || strengthRule === undefined) continue;
470
+ if (!PHYSICS_COMPONENTS.some((k) => physics.components[k] > 0) || !strengthRule.poseOk(physics.setup.strength)) continue;
471
+ fail(
472
+ 'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
473
+ `animation "${animation}" poses physics "${constraint.name}" strength 0 at every time it plays — ${keys} key(s) from t=0, ` +
474
+ `every one 0 and no curve between them lifting it, so ${physicsOutsideSays(strengthRule, 0)}: a strength key of 0 is ` +
475
+ 'accepted for a span because a later key pulls the offset back, and this animation has none, so for as long as it ' +
476
+ 'plays, looped or held on its last frame, a push (wind, gravity, the bone moving) carries the offset away and it ' +
477
+ 'grows without limit; key strength above 0 somewhere in the animation, or remove the timeline',
478
+ );
479
+ }
480
+ }
419
481
  }
420
482
 
421
483
  /** The three timelines whose values the stability clause combines. */
@@ -453,10 +515,23 @@ function plain(value: number): string {
453
515
  * The stability clause's sentence: the four authored numbers, `k` and the bound
454
516
  * as the runtime forms them, what the step does at that pose, and the four
455
517
  * levers that move it back under the bound.
518
+ *
519
+ * ⚠️ One pose fails with `k` under the bound: a damping the row accepts — below
520
+ * 1 — whose decay `damping ** (60 * step)` the runtime's doubles round to exactly
521
+ * 1 (issue #1422: the largest double below 1 does from fps 121 on). That is the
522
+ * damping row's own fault, a velocity that never decays, reached through the
523
+ * rate, so it says so rather than printing a `k` that is under its bound.
456
524
  */
457
525
  function runawaySays(combo: StabilityCombo, step: number, at: PhysicsStepStability): string {
458
526
  const src = (c: Candidate): string => (c.from === 'setup' ? '' : ` (${c.from})`);
459
527
  const fps = plain(1 / step);
528
+ if (!(at.d < 1)) {
529
+ return (
530
+ `damping ${combo.damping.value}${src(combo.damping)} at fps ${fps} — damping^(60·step) = ${at.d} as the runtime forms it, ` +
531
+ 'so although the damping is below 1 no velocity ever decays: the step\'s eigenvalues lie on the unit circle, the fault ' +
532
+ 'the damping row refuses 1 for, reached here by rounding; lower damping or lower fps'
533
+ );
534
+ }
460
535
  const places = apart(at.k, at.bound, 4);
461
536
  return (
462
537
  `strength ${combo.strength.value}${src(combo.strength)}, mass ${plain(1 / combo.massInverse.value)} (massInverse ${combo.massInverse.value})${src(combo.massInverse)}, ` +