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 +4 -1
- package/docs/AUTHORING.md +254 -30
- package/package.json +1 -1
- package/src/compile.ts +215 -14
- package/src/deformmeasure.ts +583 -38
- package/src/validate.ts +81 -1
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
|
-
|
|
1410
|
-
|
|
1411
|
-
`
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
[measured]
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
`
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
|
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
|
|
2718
|
-
it selects nothing. 🚨
|
|
2719
|
-
(§3.5.2)
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
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°
|
|
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.
|
|
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
|
-
//
|
|
3471
|
-
//
|
|
3472
|
-
//
|
|
3473
|
-
//
|
|
3474
|
-
//
|
|
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
|
-
//
|
|
3489
|
-
// you write this axis under
|
|
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
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
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 ${
|
|
3508
|
-
|
|
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
|
-
|
|
3712
|
+
`that is the form a face axis wants — or ${dead.repair}.`,
|
|
3512
3713
|
);
|
|
3513
3714
|
}
|
|
3514
3715
|
}
|