spine-rigc 0.18.0 → 0.19.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/cli.ts CHANGED
@@ -768,9 +768,12 @@ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>):
768
768
  (key.dial === null
769
769
  ? ''
770
770
  : `, dial ${key.dial.value.toFixed(6)}` +
771
+ // ⚠️ `reach.drive` names the field when it is NOT the one `property`
772
+ // names, which happens under `local: false` on a rotated parent — a
773
+ // bare figure there would read as a value of the wrong field (#419).
771
774
  (key.reach.local || key.dial.driven === key.dial.value
772
775
  ? ''
773
- : ` (bone local ${key.dial.driven.toFixed(6)})`) +
776
+ : ` (bone local ${key.reach.drive === null ? '' : `${key.reach.drive} `}${key.dial.driven.toFixed(6)})`) +
774
777
  ` -> t=${key.dial.applied.toFixed(6)}`),
775
778
  );
776
779
  // A key at a time no dial selects: the figures below are the frame the
package/docs/AUTHORING.md CHANGED
@@ -1406,27 +1406,172 @@ sharing one of those overwrite each other whatever you write. A40 names that cas
1406
1406
  separately, because the fix is different: key such a property from one slider
1407
1407
  only, or move both edits into the single animation one slider applies.
1408
1408
 
1409
- 🚨 **A `rotate`-driven slider with `local: false` cannot cross 0°.** `local: false`
1410
- reads the bone's **world** rotation through `FromRotate.value`, which ends
1411
- `if (value < 0) value += 360`. A yaw axis authored the natural way neutral at 0°,
1412
- range −15°..+15° therefore never sees a negative value: the bone at −15° arrives
1413
- as 345°, and `time = to + (value from) * scale` lands far past the animation.
1414
- [measured] with `from: 0, to: 0.5, scale: 0.033333` over a 1 s animation the dial
1415
- at −15° applies time **12.000 s**, so with `loop: false` the entire negative half
1416
- of the axis holds the last frame — 15° of face jumping between −0.001° and 0°, with
1417
- nothing anywhere reporting it. The same rig with `"local": true` ramps
1418
- 0 s 0.5 s 1 s as written.
1419
-
1420
- **`local: true` is the form a face axis wants.** It reads `source.rotation`
1421
- signed and unwrapped, which for a dial bone is the number you authored. rigc
1422
- refuses the crossing case at compile, with that arithmetic in the message; the
1423
- alternative it leaves open is to move the range so it does not cross 0° (a neutral
1424
- at 180°, say), which is the only form `local: false` can express.
1425
-
1426
- ⚠️ **The refusal is on the LOW end only.** `[0, 360)` is the whole of what
1427
- `FromRotate.value` returns, so a range running past **360°** is dead in the same
1428
- way and compiles clean. Nothing refuses that at compile; `A39` reports it from the
1429
- artifact side as a key at a time no dial selects (§4.11.4).
1409
+ #### 3.5.2.1 What each `property` can actually be read AS
1410
+
1411
+ **A `property` under `local: false` is read through the world transform, and four
1412
+ of those readings are bounded.** A range that names values the reader cannot
1413
+ return is dead there: the dial moves, the reading does not follow, and nothing at
1414
+ runtime says so. [measured] against `spine-core` 4.3.13, one reader at a time
1415
+ `bench/studies/2026-09-06-readers`:
1416
+
1417
+ | `property` | `"local"` | Reads | Producible floor | Producible ceiling |
1418
+ | --- | --- | --- | --- | --- |
1419
+ | `rotate` | `false` | `FromRotate`, world | `0`, reached | `360`, reached |
1420
+ | `rotate` | `true` | `FromRotate`, local | none | none |
1421
+ | `x` | `false` | `FromX`, world | none | none |
1422
+ | `x` | `true` | `FromX`, local | none | none |
1423
+ | `y` | `false` | `FromY`, world | none | none |
1424
+ | `y` | `true` | `FromY`, local | none | none |
1425
+ | `scaleX` | `false` | `FromScaleX`, world | `0`, reached | none |
1426
+ | `scaleX` | `true` | `FromScaleX`, local | none | none |
1427
+ | `scaleY` | `false` | `FromScaleY`, world | `0`, reached | none |
1428
+ | `scaleY` | `true` | `FromScaleY`, local | none | none |
1429
+ | `shearY` | `false` | `FromShearY`, world | `-449.99999468`, reached | `269.99999468`, reached |
1430
+ | `shearY` | `true` | `FromShearY`, local | none | none |
1431
+
1432
+ - **`none` is not "very large"** — those readers are `source.<field> + offset` and
1433
+ the field is whatever the animation wrote, so there is nothing there to bound.
1434
+ - **Every world row assumes the offsets are zero**, and a slider cannot make them
1435
+ anything else: `Slider.offsets` is a private all-zero array. The same six
1436
+ classes serve a transform constraint, which passes its own — there the scale
1437
+ floors move to the offset and the `shearY` window slides by it.
1438
+ - **`scaleX` / `scaleY` under `local: false` lose the sign.** The reader is
1439
+ `Math.sqrt(a² + c²)`, so a bone at `scaleX: −1` reads **`+1`**, not `−1`: a
1440
+ squash axis driven through negative scale gets the mirror of the dial you wrote.
1441
+ The floor `0` is *reached*, not approached — a bone whose own scale or whose
1442
+ parent's is 0 reads exactly 0 — so a range whose bottom is exactly 0 is fine and
1443
+ one that dips below it is dead.
1444
+ - **`shearY` under `local: false` wraps like `rotate` does, and worse.** It is a
1445
+ difference of two `atan2` calls, so at any one bone orientation the readable
1446
+ window is 360° wide — `(−270 − θx, 90 − θx]`, where `θx` is the bone's world
1447
+ x-axis angle. The bound in the table is the union over every orientation. ⇒ the
1448
+ seam is **not at a fixed value of the driven field**; it is wherever the bone is
1449
+ pointing. Prefer `local: true` for a shear axis.
1450
+ - **The bounds are not round numbers because `MathUtils.PI` is `3.1415927`** — the
1451
+ float32 π of the reference runtime. Every degree in spine-core passes through
1452
+ `180 / 3.1415927`, so a full turn converts as `359.99999468178214` and a bone at
1453
+ 360° reads 5.3e-6° rather than 0°. `shearY`'s two ends are `±2π · radDeg − 90`.
1454
+ - 🔸 A negative `skeleton.scaleX` / `scaleY` — how a consumer mirrors a character —
1455
+ changes **nothing**: every world reader divides the same factor back out, and
1456
+ [measured] the readings are identical to the digit at (1,1), (−1,1), (1,−1),
1457
+ (−1,−1), (2,0.5) and (−0.5,3). A `skeleton` scale of **zero** makes every world
1458
+ reader `NaN`, and `Math.max(0, NaN)` is NaN — but nothing in skeleton data sets
1459
+ that field, so it is the consumer's to avoid.
1460
+
1461
+ ⚠️ **`local: true` reads the number you authored only on a bone nothing else
1462
+ drives.** `Slider.update` calls `bone.appliedPose.validateLocalTransform` first,
1463
+ and on a bone a constraint moved that recomputes the local pose *from the world
1464
+ matrix* — `atan2Deg` for the angles and `Math.sqrt` for `scaleX`. [measured] on
1465
+ one rig, the same slider: a free bone at `rotation: −500` reads `−500` and the
1466
+ same bone inside a transform constraint's `bones` reads `−140.000006`; at
1467
+ `scaleX: −2` the free bone reads `−2` and the constrained one reads `+2`. The
1468
+ producible *set* is unbounded either way — the free case is in it — but if
1469
+ `local: true` is your repair for a world reader's floor, check that the driving
1470
+ bone is not itself constrained.
1471
+
1472
+ #### 3.5.2.2 The circle a `rotate` world dial has to stay inside
1473
+
1474
+ 🚨 **A `rotate`-driven slider with `local: false` has to stay inside the circle
1475
+ `[0, 360]`.** `local: false` reads the bone's **world** rotation through
1476
+ `FromRotate.value`, which is a `Math.atan2` — so `(−180, 180]` — with
1477
+ `if (value < 0) value += 360` on the end, and the `offsets` a slider hands it are
1478
+ all zero. `[0, 360]` is therefore the whole set of values that reader can ever
1479
+ return, and a range leaving it on either side is a wall:
1480
+
1481
+ - **Below 0°.** A yaw axis authored the natural way — neutral at 0°, range
1482
+ −15°..+15° — never sees a negative value: the bone at −15° arrives as 345°, and
1483
+ `time = to + (value − from) * scale` lands far past the animation. [measured]
1484
+ with `from: 0, to: 0.5, scale: 0.033333` over a 1 s animation the dial at −15°
1485
+ applies time **12.000 s**, so with `loop: false` the entire negative half of the
1486
+ axis holds the last frame — 15° of face jumping between −0.001° and 0°.
1487
+ - **Past 360°.** There is nothing above 360 to wrap *from*, so the value arrives
1488
+ 360 **lower** instead. [measured] with `from: 300, to: 0, scale: 0.005` over a
1489
+ 1 s animation the range is 300°..500°; the bone turned to 500° has a world
1490
+ rotation of 140°, is read as 140°, and applies time **−0.800 s** —
1491
+ `Math.max(0, time)` under `loop: false`. Swept through spine-core, that rig
1492
+ reaches only **0.000 s..0.2995 s of its own 1 s animation**: the top 140° of the
1493
+ dial does not merely fail to arrive, it arrives somewhere else.
1494
+
1495
+ Both are refused at compile, each with its own arithmetic in the message and its
1496
+ own repair — *"move the range so it does not cross 0°"* and *"move the range so it
1497
+ does not run past 360°"*.
1498
+
1499
+ ⭐ **The reading in that message is a modulo, not one turn** — the wrap the bone's
1500
+ matrix has already applied by the time `atan2` reads it, so a range that leaves
1501
+ the circle by *more* than 360° is folded all the way back into `[0, 360)`. The two
1502
+ examples above each sit within one turn, where a single ±360 gives the same
1503
+ answer; past that only the modulo does. [measured] through spine-core, a bone
1504
+ parked at **−500°** drives the slider to **3.600000 s**, which is exactly where a
1505
+ bone parked at **220°** drives it — so the reading is 220°, not −140°, and a
1506
+ refusal naming −140° would be naming a value that reader cannot return at all
1507
+ (issue [#431](https://github.com/firejune/rigc/issues/431)). The same on the other
1508
+ side: **900°** drives it to **0.200000 s**, the time a bone at **180°** selects.
1509
+
1510
+ 📐 **The consequence in that message is computed, not described.** Both refusals
1511
+ end on two numbers read off `[0, 360]` met with the driving values that reach the
1512
+ animation — the same two the message has already printed:
1513
+
1514
+ ```
1515
+ reachable = { to + (v − from) × scale : v ∈ [0, 360] } ∩ [0, duration]
1516
+ held = { v ∈ [0, 360] : the mapped time falls outside [0, duration] }
1517
+ ```
1518
+
1519
+ so the 300°..500° dial above is refused with *"This dial reaches only
1520
+ 0.000s..0.300s of the animation's 1s, and 83.3% of the circle — every reading
1521
+ below 300.000° — is held on the frame at 0.000s"*, and the −15°..15° one with
1522
+ *"…only 0.500s..1.000s of the animation's 1s, and 95.8% of the circle — every
1523
+ reading above 15.000° — is held on the frame at 1.000s"*. [measured] the first of
1524
+ those reproduces a 0.1° sweep of the rig through `spine-core` to **3.2e-8 s**,
1525
+ which is the reader's own `atan2` noise.
1526
+
1527
+ ⚠️ **`loop: true` gets a different sentence, because it is a different runtime.**
1528
+ `Slider.js:63-66` is `p.time = duration + (p.time % duration)` when the slider
1529
+ loops and `Math.max(0, p.time)` when it does not, so nothing is held on a looping
1530
+ slider — [measured] the same 300°..500° rig pins 5⁄6 of the circle to frame 0 at
1531
+ the default and pins *nothing* under `loop: true`. The refusal says so: *"Nothing
1532
+ is held: `"loop": true` wraps the time as `duration + (time % duration)`, so the
1533
+ 140.000° of the range past 360° selects nothing a reading inside the circle does
1534
+ not already select."* The range is still refused either way — a bone cannot be
1535
+ read at 500°, whatever happens to the time afterwards.
1536
+
1537
+ ⭐ **The degrees in that sentence are a *width*** — how much of `lowest..highest`
1538
+ lies outside `[0, 360]` — and not the reach from the boundary to the far end. The
1539
+ two are the same number for a range that *straddles* a boundary, as `300°..500°`
1540
+ does. A range lying **wholly** outside is told its own width instead: `400°..500°`
1541
+ reads *"the 100.000° of the range past 360°"*, and `-500°..-300°` *"the 200.000°
1542
+ of the range below 0°"* (issue
1543
+ [#434](https://github.com/firejune/rigc/issues/434) — both used to print the
1544
+ reach, which on the first of those was 140.000°, wider than the 100°-wide range
1545
+ it was describing).
1546
+
1547
+ ⭐ **A range ending exactly on 360° is legal**, and that is the whole turn: a
1548
+ wheel, a turntable, a head that goes all the way round, written `from: 0` with a
1549
+ `scale` that puts 360° on the last frame. It misses **nothing**: [measured] the
1550
+ wrap `value += 360` on a reading a hair below zero *rounds*, and bisecting the
1551
+ runtime's own `v + 360 === 360` puts the threshold at exactly half an ulp of 360 —
1552
+ so every reading in `[-2.842170943040401e-14°, 0°)` is read as exactly `360`, and
1553
+ the top of that range is reached rather than approached. Nor is 360 a separate
1554
+ dial position: a bone at 360° *is* a bone at 0°, and [measured] it poses the
1555
+ skeleton to within **4e-7°** of it, an `atan2` artefact rather than a frame. (The
1556
+ other half of that same `atan2` leaves a 5.3e-6°-wide hole at 180°, between
1557
+ `179.99999734…` and `180.00000265…` — measured, reported for completeness, and
1558
+ narrower than any dial anybody writes.) Swept at 0.1° over the circle, `from: 0,
1559
+ scale: 0.0025` on a 0.9 s animation lands every reading within **1.7e-8 s** of the
1560
+ time the mapping asks for; on `loop: true` the endpoint is not even distinct,
1561
+ closing on **0.900000 s** exactly. Use `loop: true` for a dial that really does go
1562
+ round, so the seam at 0°/360° is the wrap it is meant to be.
1563
+
1564
+ ⇒ **`local: true` is the form a face axis wants**, and it is the first repair both
1565
+ messages name. It reads `source.rotation` signed and unwrapped, which for a dial
1566
+ bone is the number you authored — 500° included — because `FromRotate`'s wrap is
1567
+ not on that path at all. The alternative each refusal leaves open is to move the
1568
+ range inside the circle (a neutral at 180°, say), which is the only form
1569
+ `local: false` can express.
1570
+
1571
+ ⚠️ **An artifact can still carry a dead range** — one exported from the editor,
1572
+ hand-edited, or built by an older rigc. `A39` reports that from the artifact side
1573
+ as a key at a time no dial selects (§4.11.4); the compile refusal above is what
1574
+ stops a rig spec in this repository from producing one.
1430
1575
 
1431
1576
  📌 **A slider's animation is measured in the slider's own frame** — `A39` inverts
1432
1577
  the mapping above and drives the bone to the value it names, rather than playing
@@ -2519,7 +2664,7 @@ deform (what each key does to the geometry — figures with names, never a bar;
2519
2664
  | Row | What it is |
2520
2665
  | --- | --- |
2521
2666
  | the `DEFORM` line | `animation`, the `skin/slot/attachment` triple the timeline is keyed on, the key's index — **the same index `A39`'s message names** — its time, and its model: the `transform` kind and parameters the spec stated (§4.11.1), or `authored table` |
2522
- | `frame` | how the animation was reached, which is the pose everything below was measured in: `played on a track`, or the slider that applies it with the dial value its mapping inverts this time to (§4.11.4). On a key at a time no dial selects, an `unreachable` line follows it and the figures below belong to the frame the runtime landed on instead |
2667
+ | `frame` | how the animation was reached, which is the pose everything below was measured in: `played on a track`, or the slider that applies it, the property that slider reads off its bone **as the emitted skeleton names it**, and the dial value its mapping inverts this time to (§4.11.4). Under `local: false` a further clause appears when the field that measurably moves the reading is not the one the skeleton names, or when two fields move it equally — reported, never resolved out of sight. On a key at a time no dial selects, an `unreachable` line follows it and the figures below belong to the frame the runtime landed on instead |
2523
2668
  | `moved` | how many vertices this key moves at all, and the largest **world** displacement with the vertex carrying it. Not the same number as §4.11.1's `largest offset`: that one is the offset the spec stated, this one is where the vertex ended up after the bones |
2524
2669
  | `area` | signed area **after ÷ before**, its smallest and largest over the triangles, each with the triangle. `x0.637` is a band compressed to 64%; **a negative ratio is a triangle turned inside out** |
2525
2670
  | `stretch` | the two singular values of the map from the cleared triangle to the deformed one — the worst stretch and the worst squash the **drawing** takes. `σ₁·σ₂ = \|area ratio\|`, so the two rows are two readings of one map and cannot disagree |
@@ -2714,13 +2859,14 @@ dial and not reached at all through another.
2714
2859
  - ⚠️ **A key at a time no dial can select is named, not passed.** The cause is
2715
2860
  [#405](https://github.com/firejune/rigc/issues/405)'s wrap: `FromRotate.value`
2716
2861
  under `local: false` is an `atan2` ending `if (value < 0) value += 360`, so
2717
- **`[0, 360)` is the whole of its range** and a mapping needing anything outside
2718
- it selects nothing. 🚨 The compiler refuses a range that dips **below**
2719
- (§3.5.2) and says nothing about one running **past 360°** `from: 300` with
2720
- `scale: 0.005` over a 1 s animation needs 300°..500° and compiles clean — so
2721
- this is the surface that sees the second half. `A39` measures the frame the
2722
- runtime *does* land on, leaves the key out of `deformKeysMeasured`, and names
2723
- it:
2862
+ **`[0, 360]` is the whole of its range** and a mapping needing anything outside
2863
+ it selects nothing. 🚨 A **rig spec** can no longer ask for one — the compiler
2864
+ refuses both ends of that circle (§3.5.2), the low one since #405 and the high
2865
+ one since [#417](https://github.com/firejune/rigc/issues/417) but an
2866
+ **artifact** can, because it may have come from the editor, from a hand edit or
2867
+ from an older rigc, and this is the surface that reads what actually shipped.
2868
+ `A39` measures the frame the runtime *does* land on, leaves the key out of
2869
+ `deformKeysMeasured`, and names it:
2724
2870
 
2725
2871
  ```
2726
2872
  deformKeysUnreachable=2 deformUnreachable=turn/head/head#0@10.800000,turn/head/head#1@11.300000
@@ -2730,6 +2876,83 @@ deformSpansNotScanned=2
2730
2876
  — the `@` is the time the runtime lands on instead — with the `DEFORM` block
2731
2877
  giving the whole sentence and the spans those keys bound left unscanned rather
2732
2878
  than scanned over two poses of some other time.
2879
+ - 🚨 **A dial past `±16777216` is named, not printed.** The drive is a number you
2880
+ are meant to act on — *set `knob.scaleX` to this* — and past 2²⁴ a float32 no
2881
+ longer separates consecutive integers, so a figure up there is not one anybody
2882
+ can set and read back. `A39` leaves the bone at its setup value instead, counts
2883
+ the key as unreachable, and says so with both numbers:
2884
+
2885
+ ```
2886
+ unreachable A39 gates nothing here: the dial for t=1s is out of bounds: slider "dial" maps
2887
+ that time back to 17000000.000000, which needs knob.rotate at 1.7000e+7 — past the ±16777216
2888
+ this solve will drive a bone field to. …
2889
+ ```
2890
+
2891
+ The usual cause is a `from` or a `scale` that puts the animation's own range out
2892
+ of reach; check the two against `value = from + (time − to) / scale` above.
2893
+
2894
+ #### Which property the `frame` line names
2895
+
2896
+ The name on the `frame` line — `off yaw_dial.rotate (local)` — is read off the
2897
+ **emitted skeleton**: it is whichever `FromProperty` the parser built out of your
2898
+ `property` field, so it is the word you wrote. rigc *also* probes all six local
2899
+ fields of the driving bone to find which one moves the reading, because under
2900
+ `local: false` the reader goes through the world transform and only a measurement
2901
+ can say what drives it there. **The two answers must agree, and when they do not
2902
+ the line says so rather than picking one silently.**
2903
+
2904
+ - Under `local: true` they always agree and the line carries nothing extra.
2905
+ - Under `local: false` they can legitimately differ, because the reading is a
2906
+ *world* one. A bone whose parent is turned 90° has a world x that its own local
2907
+ `x` does not move at all and its local `y` moves entirely, so the line reads
2908
+ `off knob.x (world), driven through knob.y — the probe moves the reading by
2909
+ knob.y 1.000e+0 while the skeleton says the reader is knob.x, which moves it by
2910
+ 2.321e-8. The two disagree and both are reported…`, and the `bone local` figure
2911
+ beside the dial names its field (`bone local y 398.999991`).
2912
+ - At 45° the two fields move it *equally*, which no ranking can separate; the line
2913
+ says the probe did not settle it and that the skeleton's own reader broke the
2914
+ tie. ⛔ Neither case is a refusal and neither is guessed past — an ambiguous
2915
+ discovery is a thing to report.
2916
+
2917
+ **And a `build` says it too, on `A39`'s stats line**
2918
+ ([#427](https://github.com/firejune/rigc/issues/427)) — because `explain` is not
2919
+ the loop you run, and until this the whole finding lived on a line only `explain`
2920
+ prints. Nothing appears on a rig where the two answers agreed, which is every
2921
+ `local: true` slider and every gallery example:
2922
+
2923
+ ```
2924
+ deformDialsTied=1 deformDialTied=dial|artifact:knob.x@7.071e-1|tied:knob.y@7.071e-1
2925
+ deformDialsDisagreed=1 deformDialDisagreed=dial|artifact:knob.x@2.321e-8|reaches:0.000000..0.003893s|probe:knob.y@1.000e+0|reaches:0.000000..1.000000s|outside:0.500000s+1.000000s
2926
+ ```
2927
+
2928
+ - `artifact:` is the field the **skeleton** names and what one unit of it moves the
2929
+ reading by; `probe:` is the field that **measurably** moves it and by how much.
2930
+ - `reaches:` is the part of that animation's own `0..duration` each of them can
2931
+ select. It is bounded by the same `±16777216` the dial figure is: a field that
2932
+ barely moves the reading needs an unsettable value to move it a whole second.
2933
+ - `outside:` is the key times this survey posed through `probe:` that **no
2934
+ settable value of the field the skeleton names reaches**. Each one is posed
2935
+ through that field and the runtime is asked where it landed, so the list is a
2936
+ measurement. ⭐ `outside:none` is a reading, not an absence — it says both
2937
+ answers select every frame that was measured, so the disagreement changed
2938
+ nothing about what `A39` looked at.
2939
+ - A **tie** never carries `probe:`, `reaches:` or `outside:`, and never counts as a
2940
+ disagreement. There is one belief there, not two.
2941
+
2942
+ ⛔ **None of it refuses a build**, and the reason is measured rather than chosen.
2943
+ The field the survey drives is the largest response the probe found, so its reach
2944
+ always *contains* the artifact's: a disagreement cannot make `A39` miss a frame the
2945
+ runtime reaches. And every frame it does pose is checked against `SliderPose.time`
2946
+ by spine-core itself, so it cannot make `A39` pose one that never happens either.
2947
+ What is left is a rig naming a property no settable value of turns far enough —
2948
+ which the line above tells you, and which no edit rigc could demand would fix,
2949
+ because the rig may be perfectly correct and driven through the other field.
2950
+
2951
+ None of this needs anything from you unless a `frame` line or one of those stats
2952
+ readings appears. If one does, it is telling you the dial bone's parent transform
2953
+ is doing something you may not have intended — and if `outside:` names times, it is
2954
+ telling you the dial cannot be turned to them through the property your rig spec
2955
+ declares.
2733
2956
 
2734
2957
  ⚠️ **What the artifact cannot say, and rigc therefore does not:** whether a
2735
2958
  slider's animation is *also* played on a track somewhere. Nothing in skeleton data
@@ -2905,7 +3128,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
2905
3128
  | `rig constraint "X": applies animation "Y", which the motion spec does not declare (it declares: …)` | §3.5.2 — fix the slider's `animation`, or add it to the motion spec |
2906
3129
  | `rig constraint "X": declares both a "bone" and "time"` | §3.5.2 — `bone` picks the model and `time` belongs to the other one |
2907
3130
  | `rig constraint "X": declares "property" but no "bone"` | §3.5.2 — name the driving bone, or key `slider.<name>.time` instead |
2908
- | `rig constraint "X": drives off bone "Y" rotate with "local": false, and the driving values that reach animation "A" (0s..Ds) run from −15.000° to 15.000° …` | §3.5.2 — add `"local": true`, which reads the bone's own rotation signed and unwrapped, or move the range so it does not cross 0°. A world rotation is wrapped into `[0, 360)` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
3131
+ | `rig constraint "X": drives off bone "Y" rotate with "local": false, and the driving values that reach animation "A" (0s..Ds) run from −15.000° to 15.000° the whole part of the range below 0° is dead` | §3.5.2 — add `"local": true`, which reads the bone's own rotation signed and unwrapped, or move the range so it does not cross 0°. A world rotation is wrapped into `[0, 360]` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
3132
+ | `… run from 300.000° to 500.000° … the whole part of the range past 360° is dead` | §3.5.2 — the same wall at the other end, and the same first repair: `"local": true`, or move the range so it does not run past 360°. `[0, 360]` is the whole of what that reader returns, so a bone turned to 500° is read as 140° and selects a time far from the one the range asked for. Ending *exactly* on 360° is fine — that is the full turn, and it misses nothing: the wrap rounds, so a bone a hair below 0° is read as exactly 360 |
2909
3133
  | `skin "S" activates bone "B", but that bone does not declare \`"skin": true\`` | §3.4.1 — the list and the flag are one switch; add the flag or drop the list |
2910
3134
  | `bone "B" declares \`"skin": true\` but no skin activates it` | §3.4.1 — the other half: list it in the skin it belongs to, or drop the flag |
2911
3135
  | `skin "S": uses the long form … and also has a key "X"` | §3.4.1 — move the slot inside `attachments` |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/compile.ts CHANGED
@@ -3265,6 +3265,21 @@ type RigConstraintInput = { name: string; type: string } & Record<string, unknow
3265
3265
  /** The six property names a transform constraint may map between (`:241`, `:521`). */
3266
3266
  const TRANSFORM_PROPERTIES = ['rotate', 'x', 'y', 'scaleX', 'scaleY', 'shearY'];
3267
3267
 
3268
+ /**
3269
+ * How far past a boundary an end of a `rotate` slider's driving range may sit
3270
+ * before the refusal below fires, in degrees.
3271
+ *
3272
+ * ⭐ **Outward at both ends**, which is the whole reason it is named: 0° and 360°
3273
+ * are the two values an author of a face axis or a full-circle dial actually aims
3274
+ * at, and `from − to / scale` is three authored numbers and a division, so an
3275
+ * intended 0° can arrive as `-1e-13` and an intended 360° as `360.0000000001`.
3276
+ * The slack exists so arithmetic noise around either aim is not a refusal.
3277
+ *
3278
+ * Nothing is tuned. The failure it separates from is gross: a wrapped reading
3279
+ * moves the applied time by `360 · scale`.
3280
+ */
3281
+ const SLIDER_WRAP_SLACK = 1e-6;
3282
+
3268
3283
  /** What a constraint's names resolve against. */
3269
3284
  interface ConstraintContext {
3270
3285
  boneNames: Set<string>;
@@ -3467,11 +3482,14 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3467
3482
  // let value = Math.atan2(source.c / sy, source.a / sx) * radDeg + …;
3468
3483
  // if (value < 0) value += 360;
3469
3484
  //
3470
- // so a driving value below 0° is one the runtime never produces: it arrives
3471
- // as `value + 360` instead, and `time = to + (value - from) * scale` then
3472
- // lands far outside the animation. A yaw axis with its neutral at — the
3473
- // natural way to author a face therefore has its entire negative half
3474
- // pinned to one frame, and nothing anywhere says so (issue #402).
3485
+ // `Math.atan2` returns `(-180, 180]` and the `offsets` a slider hands that
3486
+ // reader are `Slider.offsets`, a private all-zero array so **`[0, 360)`
3487
+ // is the whole of what it can ever produce**. A driving value outside that
3488
+ // is one the runtime never returns: it arrives 360° away, and
3489
+ // `time = to + (value - from) * scale` then lands far outside the
3490
+ // animation. A yaw axis with its neutral at 0° — the natural way to author
3491
+ // a face — therefore has its entire negative half pinned to one frame, and
3492
+ // nothing anywhere says so (issue #402).
3475
3493
  //
3476
3494
  // ⭐ Refused here rather than documented, and rather than left to the gate,
3477
3495
  // for three reasons. The failure is invisible AND total, so a note in a
@@ -3483,10 +3501,35 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3483
3501
  // has in front of it: the range of driving values that can reach a frame is
3484
3502
  // exactly `[to, to + duration]` mapped back through `scale`.
3485
3503
  //
3504
+ // ⚠️ **Both ends, out of one piece of arithmetic** (issue #417). As #405
3505
+ // landed it, `highest` was computed and never tested, so a range running
3506
+ // PAST 360° — `"from": 300, "scale": 0.005` over a 1 s animation asks for
3507
+ // 300°..500° — compiled clean with everything above 360° just as dead: turn
3508
+ // the bone to 500° and its world rotation is 140°, read as 140°, mapped to
3509
+ // a time nowhere near the one the author meant. A copy-pasted second branch
3510
+ // with a sign edited is how such a pair drifts apart, so the two ends share
3511
+ // `dead` below and every number in the message comes off it.
3512
+ //
3513
+ // ⭐ **And the line is PAST 360°, not at it** — measured before it was
3514
+ // written, because the two are one degree of arc apart and only one of them
3515
+ // costs an author anything. A range ending exactly on 360° misses exactly
3516
+ // one value, its own supremum, and that value is not a dial position: a
3517
+ // bone at 360° IS a bone at 0°, reads within 5.3e-6° of it and poses the
3518
+ // skeleton identically. Swept through spine-core over 0°..360° at 0.1°, a
3519
+ // `from: 0, scale: 0.0025` dial on a 0.9 s animation lands every reading
3520
+ // within **1.7e-8 s** of the time the mapping asks for, and on `loop: true`
3521
+ // the endpoint is not even distinct — the circle closes on 0.900000 s
3522
+ // exactly. Above 360° the dead set has width instead: 300°..500° reaches
3523
+ // only 0.000 s..0.2995 s of its own 1 s animation, and the dial positions
3524
+ // an author would set for the top 140° of it are read as something else and
3525
+ // land on a wrong frame. That silent wrong answer is the hazard, and it
3526
+ // starts strictly above 360.
3527
+ //
3486
3528
  // The refusal is deliberately narrow: `rotate` only, because `FromRotate` is
3487
3529
  // the one property whose reader wraps, and only when the range actually
3488
- // crosses (or sits below) 0°, because a dial authored at 340°..20° is how
3489
- // you write this axis under `local: false` and it works.
3530
+ // runs outside `[0, 360]`, because a dial inside that circle 20°..340°,
3531
+ // or the whole turn 0°..360° — is how you write this axis under
3532
+ // `local: false` and it works.
3490
3533
  if (property === 'rotate' && spec.local !== true) {
3491
3534
  const fromValue = spec.from === undefined ? 0 : needNumber(spec.from, 'from');
3492
3535
  const toTime = spec.to === undefined ? 0 : needNumber(spec.to, 'to');
@@ -3497,18 +3540,176 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3497
3540
  const atEnd = fromValue + (duration - toTime) / perUnit;
3498
3541
  const lowest = Math.min(atStart, atEnd);
3499
3542
  const highest = Math.max(atStart, atEnd);
3500
- if (lowest < -1e-6) {
3501
- const readAs = lowest + 360;
3502
- const lands = toTime + (readAs - fromValue) * perUnit;
3543
+ // Which end of the circle the range leaves. One record, so the end, its
3544
+ // reading, the time it lands on and the two clauses that name the side
3545
+ // are derived once rather than twice.
3546
+ //
3547
+ // The two tests ARE each other's mirror, and that is deliberate: each
3548
+ // gives its own boundary — 0° and 360°, the two values an author aims at
3549
+ // — the same outward slack, so only a range that genuinely leaves the
3550
+ // closed circle `[0, 360]` is refused.
3551
+ const dead =
3552
+ lowest < -SLIDER_WRAP_SLACK
3553
+ ? {
3554
+ end: lowest,
3555
+ side: 'below 0°',
3556
+ repair: 'move the range so it does not cross 0°',
3557
+ }
3558
+ : highest > 360 + SLIDER_WRAP_SLACK
3559
+ ? {
3560
+ end: highest,
3561
+ side: 'past 360°',
3562
+ repair: 'move the range so it does not run past 360°',
3563
+ }
3564
+ : null;
3565
+ if (dead !== null) {
3566
+ /**
3567
+ * What `FromRotate.value` actually returns for a bone at that end: a
3568
+ * MODULO, not one subtraction (issue #431).
3569
+ *
3570
+ * 🚨 `lowest + 360` / `highest - 360` is right only while the range
3571
+ * stays within one turn of the circle, which every fixture in this
3572
+ * tree happened to be. Further out it printed a number the reader
3573
+ * cannot return, inside a refusal whose entire subject is which
3574
+ * readings the reader CAN return: `from: -500, scale: 0.005` over a
3575
+ * 1 s animation said *"the bone at -500.000° is read as -140.000° and
3576
+ * maps to time 1.800s"*, and -140° is not in `[0, 360)` at all. Both
3577
+ * figures in that sentence were wrong. Measured through spine-core,
3578
+ * a bone parked at -500° drives `Slider.appliedPose.time` to
3579
+ * 3.600000s — the same six decimals a bone parked at 220° drives it
3580
+ * to — so the reading is 220° and the time is 3.600s.
3581
+ *
3582
+ * ⭐ Derived off `dead.end` rather than inside the two branches, so
3583
+ * the wrap is written ONCE. That is what #424 collapsed the two ends
3584
+ * into one record for, and a second copy of `% 360` with a sign
3585
+ * edited is how the pair drifts apart again.
3586
+ *
3587
+ * ⚠️ The consequence clause below does NOT go through here and does
3588
+ * not need to: `reachLo`/`reachHi` intersect the driving window with
3589
+ * `[0, 360)` directly, which is the same set however many turns out
3590
+ * the window sits. Swept through spine-core at 0.1° over the whole
3591
+ * circle on both a beyond-a-turn low range (-500°..-300°) and a
3592
+ * beyond-`+720°` high one (100°..900°), the held fraction and the
3593
+ * frame the held arc pins to are the ones this clause names — 100.0%
3594
+ * at 1.000s and 27.8% at 0.000s — and the runtime's own applied time
3595
+ * tracks the closed form to 3.3e-8s.
3596
+ */
3597
+ const readAs = ((dead.end % 360) + 360) % 360;
3598
+ /**
3599
+ * The time this mapping puts a driving value at, before the runtime
3600
+ * touches it — the one arithmetic every figure below comes off.
3601
+ *
3602
+ * ⚠️ **Computed, not asserted** (issue #423). This clause used to end
3603
+ * *"— outside the animation's Ds. With `loop`: false that is
3604
+ * `Math.max(0, time)` holding the last frame; with `loop`: true it
3605
+ * wraps to some other frame"*, which states a consequence rather than
3606
+ * measuring one — and is flatly false for a range spanning a full
3607
+ * turn, where the wrapped reading lands INSIDE the animation. It also
3608
+ * printed both loop modes and left the reader to pick. A message that
3609
+ * hedges is a message that has not measured.
3610
+ */
3611
+ const timeAt = (value: number): number => toTime + (value - fromValue) * perUnit;
3612
+ const lands = timeAt(readAs);
3613
+ // `[0, 360)` is the whole of what `FromRotate.value` returns (issue
3614
+ // #417), so the readings that reach the animation at all are that
3615
+ // circle met with the driving window `lowest`..`highest` the range
3616
+ // clause above already prints. A second derivation beside these two
3617
+ // numbers is exactly how a pair drifts, which is why #417 collapsed
3618
+ // the two ends into one `dead` record in the first place.
3619
+ const reachLo = Math.max(0, lowest);
3620
+ const reachHi = Math.min(360, highest);
3621
+ const reaches = reachLo <= reachHi;
3622
+ const intoFrame = (time: number): number => Math.min(Math.max(time, 0), duration);
3623
+ const reachA = intoFrame(timeAt(reachLo));
3624
+ const reachB = intoFrame(timeAt(reachHi));
3625
+ const span = `${Math.min(reachA, reachB).toFixed(3)}s..${Math.max(reachA, reachB).toFixed(3)}s`;
3626
+ // ⭐ Exactly ONE arc of the circle is ever left over, and that is what
3627
+ // lets the message name one bound and one frame instead of a set: the
3628
+ // refusal means `[lowest, highest]` already runs off one end of
3629
+ // `[0, 360)`, so the part of the circle outside it is a single run.
3630
+ // (Both ends outside means the whole circle reaches — `dead` is 0° wide
3631
+ // — and neither end outside is not a refusal at all.)
3632
+ const dark = reaches ? 360 - (reachHi - reachLo) : 360;
3633
+ const heldAt = intoFrame(timeAt(reachLo > 0 ? 0 : 360));
3634
+ const arc = !reaches
3635
+ ? 'every reading'
3636
+ : reachLo > 0
3637
+ ? `every reading below ${reachLo.toFixed(3)}°`
3638
+ : `every reading above ${reachHi.toFixed(3)}°`;
3639
+ /**
3640
+ * How much of the range is a bone position no reader ever returns:
3641
+ * the WIDTH of `[lowest, highest]` lying outside `[0, 360]` (issue
3642
+ * #434).
3643
+ *
3644
+ * 🚨 `-lowest` / `highest - 360` is the distance from the boundary to
3645
+ * the FAR end, and that equals the dead width only while the range
3646
+ * STRADDLES the boundary. A range lying wholly outside had the gap
3647
+ * between the boundary and its NEAR end counted too: `400°..500°` —
3648
+ * inside one turn and reachable today — was told `140.000°` of it is
3649
+ * dead, wider than the 100° range itself, and `-500°..-300°` was told
3650
+ * `500.000°` against a true 200°. Clamping the near end to the
3651
+ * boundary is the whole of the fix, and it moves ONLY the ranges that
3652
+ * lie wholly outside: measured, a range straddling either boundary, a
3653
+ * range ending exactly on one, and a range hanging off both at once
3654
+ * all print what they printed before. `PS45` is the two that move and
3655
+ * `PS46` is the four that must not.
3656
+ *
3657
+ * ⭐ **Third instance of one shape in this clause.** #417 tested one
3658
+ * end of the range because its fixture only ever left the circle at
3659
+ * that end; #431 wrapped by a single subtraction because every
3660
+ * fixture sat within one turn; this measured to the far end because
3661
+ * `PS42` — the only control that reads this string — straddles 360°,
3662
+ * where the two arithmetics agree to the bit. Every time, a
3663
+ * computation right about the case its fixture happened to be and
3664
+ * silent about the case beside it, with no second fixture standing
3665
+ * anywhere else to say so.
3666
+ *
3667
+ * ⭐ Why it read as a measurement rather than as a bug: the wrong
3668
+ * figure is always a number ALREADY IN THE SENTENCE. Past 360°,
3669
+ * `highest - 360` reproduces `readAs` — `400°..500°` printed the same
3670
+ * `140.000°` twice, once as the reading and once as a width. Below 0°,
3671
+ * `-lowest` reproduces `dead.end` with its sign dropped.
3672
+ *
3673
+ * ⚠️ Each term stays BEHIND the test that says its side is the one
3674
+ * that crossed, and those guards are load-bearing rather than tidy —
3675
+ * measured, not argued. Drop `lowest < 0` and the low term on
3676
+ * `400°..500°` is `Math.min(0, 500) - 400 = -400`, a negative
3677
+ * contribution to a width: the refusal prints `-300.000°`, and
3678
+ * `300°..500°` and `100°..900°` move to `-160.000°` and `440.000°`.
3679
+ * Drop `highest > 360` and `-340°..-305°` prints `-630.000°`. Neither
3680
+ * is a width, and a width is what the sentence says it is.
3681
+ */
3682
+ const outside =
3683
+ (lowest < 0 ? Math.min(0, highest) - lowest : 0) + (highest > 360 ? highest - Math.max(360, lowest) : 0);
3684
+ // ⚠️ WHICH consequence the runtime produces is the slider's own `loop`,
3685
+ // read rather than guessed: `Slider.js:63-66` is
3686
+ // `p.time = duration + (p.time % duration)` when it is true and
3687
+ // `Math.max(0, p.time)` when it is false, so a reading held on one
3688
+ // frame under the second is replayed from elsewhere under the first and
3689
+ // nothing is dead in time at all. Swept through spine-core at 0.1° over
3690
+ // the whole circle before this was written, both ways.
3691
+ const consequence =
3692
+ spec.loop === true
3693
+ ? `Nothing is held: "loop": true wraps the time as \`duration + (time % duration)\`, so the ` +
3694
+ `${outside.toFixed(3)}° of the range ${dead.side} selects nothing a reading inside the circle does ` +
3695
+ 'not already select.'
3696
+ : !reaches
3697
+ ? `This dial reaches none of the animation's ${duration}s — the whole circle is held on the frame at ` +
3698
+ `${heldAt.toFixed(3)}s.`
3699
+ : dark === 0
3700
+ ? `This dial reaches only ${span} of the animation's ${duration}s, and no reading of the circle ` +
3701
+ 'reaches the rest of it.'
3702
+ : `This dial reaches only ${span} of the animation's ${duration}s, and ` +
3703
+ `${((dark / 360) * 100).toFixed(1)}% of the circle — ${arc} — is held on the frame at ` +
3704
+ `${heldAt.toFixed(3)}s.`;
3503
3705
  throw new CompileError(
3504
3706
  `${where}: drives off bone "${String(spec.bone)}" rotate with "local": false, and the driving values ` +
3505
3707
  `that reach animation "${animation}" (0s..${duration}s) run from ${lowest.toFixed(3)}° to ${highest.toFixed(3)}°. ` +
3506
3708
  'A world rotation is read through `FromRotate.value`, which ends `if (value < 0) value += 360`, so the bone ' +
3507
- `at ${lowest.toFixed(3)}° is read as ${readAs.toFixed(3)}° and maps to time ${lands.toFixed(3)}s — outside ` +
3508
- `the animation's ${duration}s. With "loop": false that is \`Math.max(0, time)\` holding the last frame; with ` +
3509
- '"loop": true it wraps to some other frame. Either way the whole part of the range below 0° is dead and ' +
3709
+ `at ${dead.end.toFixed(3)}° is read as ${readAs.toFixed(3)}° and maps to time ${lands.toFixed(3)}s. ` +
3710
+ `${consequence} The whole part of the range ${dead.side} is dead and ` +
3510
3711
  'nothing at runtime reports it. Add `"local": true` to read the bone\'s own rotation signed and unwrapped — ' +
3511
- 'that is the form a face axis wants — or move the range so it does not cross 0°.',
3712
+ `that is the form a face axis wants — or ${dead.repair}.`,
3512
3713
  );
3513
3714
  }
3514
3715
  }