spine-rigc 0.18.1 → 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/docs/AUTHORING.md +166 -11
- package/package.json +1 -1
- package/src/compile.ts +144 -11
- package/src/deformmeasure.ts +264 -7
- package/src/validate.ts +81 -1
package/docs/AUTHORING.md
CHANGED
|
@@ -1406,11 +1406,76 @@ 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
|
+
#### 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
|
+
|
|
1409
1474
|
🚨 **A `rotate`-driven slider with `local: false` has to stay inside the circle
|
|
1410
1475
|
`[0, 360]`.** `local: false` reads the bone's **world** rotation through
|
|
1411
1476
|
`FromRotate.value`, which is a `Math.atan2` — so `(−180, 180]` — with
|
|
1412
1477
|
`if (value < 0) value += 360` on the end, and the `offsets` a slider hands it are
|
|
1413
|
-
all zero. `[0, 360
|
|
1478
|
+
all zero. `[0, 360]` is therefore the whole set of values that reader can ever
|
|
1414
1479
|
return, and a range leaving it on either side is a wall:
|
|
1415
1480
|
|
|
1416
1481
|
- **Below 0°.** A yaw axis authored the natural way — neutral at 0°, range
|
|
@@ -1431,12 +1496,66 @@ Both are refused at compile, each with its own arithmetic in the message and its
|
|
|
1431
1496
|
own repair — *"move the range so it does not cross 0°"* and *"move the range so it
|
|
1432
1497
|
does not run past 360°"*.
|
|
1433
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
|
+
|
|
1434
1547
|
⭐ **A range ending exactly on 360° is legal**, and that is the whole turn: a
|
|
1435
1548
|
wheel, a turntable, a head that goes all the way round, written `from: 0` with a
|
|
1436
|
-
`scale` that puts 360° on the last frame. It misses
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
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,
|
|
1440
1559
|
scale: 0.0025` on a 0.9 s animation lands every reading within **1.7e-8 s** of the
|
|
1441
1560
|
time the mapping asks for; on `loop: true` the endpoint is not even distinct,
|
|
1442
1561
|
closing on **0.900000 s** exactly. Use `loop: true` for a dial that really does go
|
|
@@ -2740,7 +2859,7 @@ dial and not reached at all through another.
|
|
|
2740
2859
|
- ⚠️ **A key at a time no dial can select is named, not passed.** The cause is
|
|
2741
2860
|
[#405](https://github.com/firejune/rigc/issues/405)'s wrap: `FromRotate.value`
|
|
2742
2861
|
under `local: false` is an `atan2` ending `if (value < 0) value += 360`, so
|
|
2743
|
-
**`[0, 360
|
|
2862
|
+
**`[0, 360]` is the whole of its range** and a mapping needing anything outside
|
|
2744
2863
|
it selects nothing. 🚨 A **rig spec** can no longer ask for one — the compiler
|
|
2745
2864
|
refuses both ends of that circle (§3.5.2), the low one since #405 and the high
|
|
2746
2865
|
one since [#417](https://github.com/firejune/rigc/issues/417) — but an
|
|
@@ -2795,9 +2914,45 @@ the line says so rather than picking one silently.**
|
|
|
2795
2914
|
tie. ⛔ Neither case is a refusal and neither is guessed past — an ambiguous
|
|
2796
2915
|
discovery is a thing to report.
|
|
2797
2916
|
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
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.
|
|
2801
2956
|
|
|
2802
2957
|
⚠️ **What the artifact cannot say, and rigc therefore does not:** whether a
|
|
2803
2958
|
slider's animation is *also* played on a track somewhere. Nothing in skeleton data
|
|
@@ -2973,8 +3128,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
|
|
|
2973
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 |
|
|
2974
3129
|
| `rig constraint "X": declares both a "bone" and "time"` | §3.5.2 — `bone` picks the model and `time` belongs to the other one |
|
|
2975
3130
|
| `rig constraint "X": declares "property" but no "bone"` | §3.5.2 — name the driving bone, or key `slider.<name>.time` instead |
|
|
2976
|
-
| `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
|
|
2977
|
-
| `… 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
|
|
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 |
|
|
2978
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 |
|
|
2979
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 |
|
|
2980
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
|
@@ -3540,11 +3540,9 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
3540
3540
|
const atEnd = fromValue + (duration - toTime) / perUnit;
|
|
3541
3541
|
const lowest = Math.min(atStart, atEnd);
|
|
3542
3542
|
const highest = Math.max(atStart, atEnd);
|
|
3543
|
-
// Which end of the circle the range leaves
|
|
3544
|
-
//
|
|
3545
|
-
//
|
|
3546
|
-
// the end, its reading, the time it lands on and the two clauses that
|
|
3547
|
-
// name the side are derived once rather than twice.
|
|
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.
|
|
3548
3546
|
//
|
|
3549
3547
|
// The two tests ARE each other's mirror, and that is deliberate: each
|
|
3550
3548
|
// gives its own boundary — 0° and 360°, the two values an author aims at
|
|
@@ -3554,27 +3552,162 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
|
|
|
3554
3552
|
lowest < -SLIDER_WRAP_SLACK
|
|
3555
3553
|
? {
|
|
3556
3554
|
end: lowest,
|
|
3557
|
-
readAs: lowest + 360,
|
|
3558
3555
|
side: 'below 0°',
|
|
3559
3556
|
repair: 'move the range so it does not cross 0°',
|
|
3560
3557
|
}
|
|
3561
3558
|
: highest > 360 + SLIDER_WRAP_SLACK
|
|
3562
3559
|
? {
|
|
3563
3560
|
end: highest,
|
|
3564
|
-
readAs: highest - 360,
|
|
3565
3561
|
side: 'past 360°',
|
|
3566
3562
|
repair: 'move the range so it does not run past 360°',
|
|
3567
3563
|
}
|
|
3568
3564
|
: null;
|
|
3569
3565
|
if (dead !== null) {
|
|
3570
|
-
|
|
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.`;
|
|
3571
3705
|
throw new CompileError(
|
|
3572
3706
|
`${where}: drives off bone "${String(spec.bone)}" rotate with "local": false, and the driving values ` +
|
|
3573
3707
|
`that reach animation "${animation}" (0s..${duration}s) run from ${lowest.toFixed(3)}° to ${highest.toFixed(3)}°. ` +
|
|
3574
3708
|
'A world rotation is read through `FromRotate.value`, which ends `if (value < 0) value += 360`, so the bone ' +
|
|
3575
|
-
`at ${dead.end.toFixed(3)}° is read as ${
|
|
3576
|
-
|
|
3577
|
-
`"loop": true it wraps to some other frame. Either way the whole part of the range ${dead.side} 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 ` +
|
|
3578
3711
|
'nothing at runtime reports it. Add `"local": true` to read the bone\'s own rotation signed and unwrapped — ' +
|
|
3579
3712
|
`that is the form a face axis wants — or ${dead.repair}.`,
|
|
3580
3713
|
);
|
package/src/deformmeasure.ts
CHANGED
|
@@ -331,6 +331,94 @@ export interface DeformReach {
|
|
|
331
331
|
label: string;
|
|
332
332
|
}
|
|
333
333
|
|
|
334
|
+
/** The span of one animation's own `0..duration` a dial can select, in seconds. */
|
|
335
|
+
export interface DialSpan {
|
|
336
|
+
lo: number;
|
|
337
|
+
hi: number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A dial whose probe **tied** and whose artifact broke the tie (issue #419).
|
|
342
|
+
*
|
|
343
|
+
* 🔒 **Not a disagreement, and it must never be reported as one.** There is one
|
|
344
|
+
* belief here, not two: the probe measured two fields moving the reading by the
|
|
345
|
+
* same amount, named neither, and the skeleton's own reader said which of them
|
|
346
|
+
* the author wrote. A `FromX` slider under `local: false` on a bone whose parent
|
|
347
|
+
* is at 45° is exactly that, and it is legitimate geometry. So there is no second
|
|
348
|
+
* answer, no second reach and nothing to compare — which is why this is a type of
|
|
349
|
+
* its own and not a `verdict` field on the one below.
|
|
350
|
+
*/
|
|
351
|
+
export interface DeformDialTie {
|
|
352
|
+
/** The slider whose dial this is. */
|
|
353
|
+
slider: string;
|
|
354
|
+
/** Its driving bone. */
|
|
355
|
+
bone: string;
|
|
356
|
+
/** The field the artifact named, which is therefore the one the survey drives. */
|
|
357
|
+
drive: string;
|
|
358
|
+
/** What one step of it moved the reading by. */
|
|
359
|
+
driveResponse: number;
|
|
360
|
+
/** The other fields inside `DIAL_PROBE_MARGIN` of it, and what each moved it by. */
|
|
361
|
+
rivals: Array<{ field: string; response: number }>;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A dial the artifact and the probe name **differently**, with the span each of
|
|
366
|
+
* their answers can select (issues #419, #427).
|
|
367
|
+
*
|
|
368
|
+
* ⭐ Structured, and on the survey rather than only inside `DeformReach.label`,
|
|
369
|
+
* because that label is prose only `explain` prints. A `build`-only run is the
|
|
370
|
+
* loop an agent that cannot see the rig actually runs, and until this existed the
|
|
371
|
+
* fact that rigc's two halves disagreed about which dial was turned reached that
|
|
372
|
+
* run not at all.
|
|
373
|
+
*
|
|
374
|
+
* 🚨 **What the two reaches settle.** The property's name is not what makes this
|
|
375
|
+
* survey trustworthy — the set of frames it poses is. So both answers are turned
|
|
376
|
+
* into the span of the animation each can select, and `outside` is the part of
|
|
377
|
+
* what was posed that the artifact's answer could not have reached. Measured
|
|
378
|
+
* (issue #427): under a disagreement `driveReach` **always contains**
|
|
379
|
+
* `statedReach`, because the driven field is the largest response the probe found
|
|
380
|
+
* and a disagreement needs it to clear the artifact's field by
|
|
381
|
+
* `DIAL_PROBE_MARGIN`. So the survey never poses fewer frames than the artifact's
|
|
382
|
+
* answer would have, and `outside` is what the artifact's answer would have
|
|
383
|
+
* MISSED — never what this one invented.
|
|
384
|
+
*/
|
|
385
|
+
export interface DeformDialDispute {
|
|
386
|
+
/** The slider whose dial this is. */
|
|
387
|
+
slider: string;
|
|
388
|
+
/** Its driving bone. */
|
|
389
|
+
bone: string;
|
|
390
|
+
/**
|
|
391
|
+
* The field the artifact names, rig-spec spelled — or the reader's class name
|
|
392
|
+
* when it is one this file does not know.
|
|
393
|
+
*/
|
|
394
|
+
stated: string;
|
|
395
|
+
/** What one step of the artifact's field moves the reading by. */
|
|
396
|
+
statedResponse: number | null;
|
|
397
|
+
/**
|
|
398
|
+
* The part of the animation's own `0..duration` the artifact's field can
|
|
399
|
+
* select, or `null` when it can select none of it — and on the one reader
|
|
400
|
+
* this file cannot name, where there is no field to probe.
|
|
401
|
+
*/
|
|
402
|
+
statedReach: DialSpan | null;
|
|
403
|
+
/** The field the survey drives, which is the one that measurably moves the reading. */
|
|
404
|
+
drive: string;
|
|
405
|
+
/** What one step of THAT moves the reading by. */
|
|
406
|
+
driveResponse: number;
|
|
407
|
+
/** The part of `0..duration` the drive can select, or `null` when it can select none. */
|
|
408
|
+
driveReach: DialSpan | null;
|
|
409
|
+
/**
|
|
410
|
+
* The deform key times this survey posed through `drive` that no settable value
|
|
411
|
+
* of the artifact's field reaches, in ascending order.
|
|
412
|
+
*
|
|
413
|
+
* ⭐ Empty is a **reading**, not an absence: it says both answers pose the same
|
|
414
|
+
* frames, so the disagreement changed nothing about what was measured. A39
|
|
415
|
+
* prints `outside:none` for it rather than omitting the field, because a
|
|
416
|
+
* comparison that was made and came out equal must not look like one nobody
|
|
417
|
+
* made.
|
|
418
|
+
*/
|
|
419
|
+
outside: number[];
|
|
420
|
+
}
|
|
421
|
+
|
|
334
422
|
/** The reach every animation has when no slider applies it. */
|
|
335
423
|
const TRACK_REACH: DeformReach = {
|
|
336
424
|
kind: 'track',
|
|
@@ -594,6 +682,17 @@ export interface DeformSurvey {
|
|
|
594
682
|
* and this is (`DW16`).
|
|
595
683
|
*/
|
|
596
684
|
spanProbes: number;
|
|
685
|
+
/**
|
|
686
|
+
* Dials whose probe tied and whose artifact broke the tie, in the skeleton's
|
|
687
|
+
* own constraint order (issue #419). Empty on every rig where it did not.
|
|
688
|
+
*/
|
|
689
|
+
dialTies: DeformDialTie[];
|
|
690
|
+
/**
|
|
691
|
+
* Dials the artifact and the probe name differently, in the same order, each
|
|
692
|
+
* carrying both answers, both reaches and the frames the artifact's answer
|
|
693
|
+
* could not have posed (issues #419, #427).
|
|
694
|
+
*/
|
|
695
|
+
dialDisputes: DeformDialDispute[];
|
|
597
696
|
}
|
|
598
697
|
|
|
599
698
|
/**
|
|
@@ -693,6 +792,8 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
693
792
|
let notReachable = 0;
|
|
694
793
|
let notReachableReversed = 0;
|
|
695
794
|
let spansNotScanned = 0;
|
|
795
|
+
const dialTies: DeformDialTie[] = [];
|
|
796
|
+
const dialDisputes: DeformDialDispute[] = [];
|
|
696
797
|
const reaches = reachesOf(data);
|
|
697
798
|
for (const anim of data.animations) {
|
|
698
799
|
// One pass per way in (issue #407). The animations nothing applies get the
|
|
@@ -701,6 +802,17 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
701
802
|
const poseFrame = (time: number): PoseOfFrame =>
|
|
702
803
|
dials === null ? { posed: poseAt(data, anim.name, time), dial: null } : poseDial(data, dials, time);
|
|
703
804
|
const reach = dials === null ? TRACK_REACH : dials.reach;
|
|
805
|
+
/**
|
|
806
|
+
* The key times this plan actually **reached**, for the reach comparison
|
|
807
|
+
* below (#427).
|
|
808
|
+
*
|
|
809
|
+
* ⚠️ A key the drive itself could not select is not in here. It is already
|
|
810
|
+
* named on the stats line as `deformKeysUnreachable`, with the ask and the
|
|
811
|
+
* bound, and the artifact's answer cannot reach it either — so listing it
|
|
812
|
+
* as something the artifact's answer missed would count one defect twice
|
|
813
|
+
* and inflate a disagreement with a frame the disagreement did not cost.
|
|
814
|
+
*/
|
|
815
|
+
const posedTimes = new Set<number>();
|
|
704
816
|
for (const timeline of anim.timelines) {
|
|
705
817
|
if (!(timeline instanceof DeformTimeline)) continue;
|
|
706
818
|
timelines++;
|
|
@@ -748,6 +860,7 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
748
860
|
notDrawnReversed += frameMeasure.measure.reversed.length;
|
|
749
861
|
}
|
|
750
862
|
keys.push({ ...named, key: frame, time, ...frameMeasure.measure });
|
|
863
|
+
if (at.dial?.unreachable !== true) posedTimes.add(time);
|
|
751
864
|
if (previous !== null) {
|
|
752
865
|
// ⚠️ A span whose end is a frame the runtime cannot reach has no
|
|
753
866
|
// interpolation to scan: the anchors it would solve the quadratic
|
|
@@ -773,6 +886,56 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
773
886
|
previous = frameMeasure;
|
|
774
887
|
}
|
|
775
888
|
}
|
|
889
|
+
// --- what the artifact's own answer could NOT have posed (issue #427) ---
|
|
890
|
+
//
|
|
891
|
+
// ⭐ Posed, not predicted. The survey builds a second plan out of the field
|
|
892
|
+
// the SKELETON names and runs it through the same `poseDial` every real
|
|
893
|
+
// frame goes through, so each entry is spine-core saying "no settable value
|
|
894
|
+
// of this field lands me on that time" rather than this file inferring it
|
|
895
|
+
// off an interval. Measured (#427): the list is empty whenever the two
|
|
896
|
+
// answers reach the same span, and empty is the reading that says the
|
|
897
|
+
// disagreement changed nothing about which frames were measured.
|
|
898
|
+
//
|
|
899
|
+
// 🚨 And nothing is reported at all about a dial that posed NOTHING — a
|
|
900
|
+
// slider whose animation carries no deform timeline. `outside` would be
|
|
901
|
+
// empty there for the one reason that must never print as agreement:
|
|
902
|
+
// there were no frames to disagree about. A comparison of nothing and a
|
|
903
|
+
// comparison that came out equal are the vacuous pass this file exists
|
|
904
|
+
// to keep apart.
|
|
905
|
+
if (posedTimes.size === 0) continue;
|
|
906
|
+
// A plan is visited exactly once — a slider names one animation — so the
|
|
907
|
+
// two lists need no de-duplication and come out in the skeleton's own
|
|
908
|
+
// constraint order.
|
|
909
|
+
if (dials?.tie) dialTies.push(dials.tie);
|
|
910
|
+
if (dials?.dispute) dialDisputes.push(dials.dispute);
|
|
911
|
+
if (dials?.dispute && dials.statedMap !== null) {
|
|
912
|
+
const shadow: DialPlan = {
|
|
913
|
+
...dials,
|
|
914
|
+
field: dials.statedMap.field,
|
|
915
|
+
u0: dials.statedMap.u0,
|
|
916
|
+
v0: dials.statedMap.v0,
|
|
917
|
+
u1: dials.statedMap.u1,
|
|
918
|
+
v1: dials.statedMap.v1,
|
|
919
|
+
};
|
|
920
|
+
// ⚠️ The one time posing cannot answer: a field that moves the reading by
|
|
921
|
+
// NOTHING has no map to invert, so `poseDial` divides by zero and calls
|
|
922
|
+
// every time out of bounds — including the setup time, which that field
|
|
923
|
+
// reaches by being left alone. The reach says which one that is, and it
|
|
924
|
+
// is a single point. Nothing in spine-core 4.3 has been measured getting
|
|
925
|
+
// here (a parent at exactly 90° still moves a world x reading by 2.3e-8),
|
|
926
|
+
// and a report that over-stated a disagreement by one key would be the
|
|
927
|
+
// false red this file has paid for twice.
|
|
928
|
+
const flat = dials.statedMap.v1 === dials.statedMap.v0;
|
|
929
|
+
const only = dials.dispute.statedReach;
|
|
930
|
+
const outside = [...posedTimes]
|
|
931
|
+
.filter((time) =>
|
|
932
|
+
flat
|
|
933
|
+
? only === null || Math.abs(time - only.lo) > DIAL_TIME_EPSILON
|
|
934
|
+
: poseDial(data, shadow, time).dial?.unreachable === true,
|
|
935
|
+
)
|
|
936
|
+
.sort((a, b) => a - b);
|
|
937
|
+
dials.dispute.outside.push(...outside);
|
|
938
|
+
}
|
|
776
939
|
}
|
|
777
940
|
}
|
|
778
941
|
let spanProbes = 0;
|
|
@@ -802,6 +965,8 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
802
965
|
spansUnconfirmed,
|
|
803
966
|
spansNotScanned,
|
|
804
967
|
spanProbes,
|
|
968
|
+
dialTies,
|
|
969
|
+
dialDisputes,
|
|
805
970
|
};
|
|
806
971
|
}
|
|
807
972
|
|
|
@@ -913,6 +1078,13 @@ interface DialDiscovery {
|
|
|
913
1078
|
interface DialPlan {
|
|
914
1079
|
slider: SliderData;
|
|
915
1080
|
reach: DeformReach;
|
|
1081
|
+
/**
|
|
1082
|
+
* The probe's tie, when it had one — the survey collects these so a `build`
|
|
1083
|
+
* sees them and not only `explain` (issues #419, #427).
|
|
1084
|
+
*/
|
|
1085
|
+
tie: DeformDialTie | null;
|
|
1086
|
+
/** The two answers and their two reaches, when they named different fields. */
|
|
1087
|
+
dispute: DeformDialDispute | null;
|
|
916
1088
|
/**
|
|
917
1089
|
* The bone field that drives it, or `null` on a bone-less slider — whose time
|
|
918
1090
|
* IS its pose value and is set directly.
|
|
@@ -923,6 +1095,17 @@ interface DialPlan {
|
|
|
923
1095
|
v0: number;
|
|
924
1096
|
u1: number;
|
|
925
1097
|
v1: number;
|
|
1098
|
+
/**
|
|
1099
|
+
* The ARTIFACT's own field as a map of its own, kept only on a disputed dial
|
|
1100
|
+
* (issue #427).
|
|
1101
|
+
*
|
|
1102
|
+
* ⭐ It is what lets `DeformDialDispute.outside` be **posed** rather than
|
|
1103
|
+
* predicted: the survey builds a second plan out of it and runs the same
|
|
1104
|
+
* `poseDial` the real frames go through, so "no settable value of `knob.x`
|
|
1105
|
+
* reaches t=0.5s" is a measurement taken against spine-core and not an
|
|
1106
|
+
* inference off an interval.
|
|
1107
|
+
*/
|
|
1108
|
+
statedMap: DialProbe | null;
|
|
926
1109
|
}
|
|
927
1110
|
|
|
928
1111
|
/**
|
|
@@ -1104,22 +1287,26 @@ function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
|
|
|
1104
1287
|
};
|
|
1105
1288
|
// The bone-less form: `Slider.update` leaves `p.time` alone, so the dial IS the
|
|
1106
1289
|
// pose value and the map is the identity.
|
|
1107
|
-
if (slider.bone === null)
|
|
1290
|
+
if (slider.bone === null) {
|
|
1291
|
+
return { slider, reach: reach(null), tie: null, dispute: null, field: null, u0: 0, v0: 0, u1: 1, v1: 1, statedMap: null };
|
|
1292
|
+
}
|
|
1108
1293
|
const skeleton = new Skeleton(data);
|
|
1109
1294
|
const instance = sliderOn(skeleton, slider);
|
|
1110
1295
|
const bone = instance?.bone ?? null;
|
|
1111
1296
|
if (instance === null || bone === null) return null;
|
|
1112
|
-
|
|
1297
|
+
// ⚠️ Every field is kept, the dead ones included, because the ARTIFACT may name
|
|
1298
|
+
// one of them: a reach comparison needs the map of the field the skeleton
|
|
1299
|
+
// declares even when that field moves the reading by nothing at all (#427).
|
|
1300
|
+
const all: DialProbe[] = [];
|
|
1113
1301
|
for (const field of DIAL_FIELDS) {
|
|
1114
1302
|
const step = dialStep(field);
|
|
1115
1303
|
skeleton.setupPose();
|
|
1116
1304
|
const base = bone.pose[field];
|
|
1117
1305
|
const v0 = dialValue(skeleton, slider, bone, field, base);
|
|
1118
1306
|
const v1 = dialValue(skeleton, slider, bone, field, base + step);
|
|
1119
|
-
|
|
1120
|
-
if (!Number.isFinite(response) || response === 0) continue;
|
|
1121
|
-
probes.push({ field, response, u0: base, v0, u1: base + step, v1 });
|
|
1307
|
+
all.push({ field, response: Math.abs(v1 - v0), u0: base, v0, u1: base + step, v1 });
|
|
1122
1308
|
}
|
|
1309
|
+
const probes = all.filter((p) => Number.isFinite(p.response) && p.response !== 0);
|
|
1123
1310
|
// Nothing moves it: a bone another constraint pins, or a reader that cannot see
|
|
1124
1311
|
// this bone at all. A37 owns the `scale: 0` shape of the same silence.
|
|
1125
1312
|
if (probes.length === 0) return null;
|
|
@@ -1140,10 +1327,80 @@ function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
|
|
|
1140
1327
|
driveResponse: chosen.response,
|
|
1141
1328
|
rivals: leaders.filter((p) => p.field !== chosen.field).map((p) => ({ field: p.field, response: p.response })),
|
|
1142
1329
|
statedResponse:
|
|
1143
|
-
stated === null || stated === chosen.field ? null : (
|
|
1330
|
+
stated === null || stated === chosen.field ? null : (all.find((p) => p.field === stated)?.response ?? 0),
|
|
1144
1331
|
verdict,
|
|
1145
1332
|
};
|
|
1146
|
-
|
|
1333
|
+
// 🔒 A tie and a disagreement are built as two different things, because they
|
|
1334
|
+
// ARE two different things: a tie has one belief the artifact broke, and a
|
|
1335
|
+
// disagreement has two that have to be compared. Folding them into one record
|
|
1336
|
+
// with a `verdict` field is how a report comes to say "disagreed" about
|
|
1337
|
+
// legitimate geometry (issues #419, #427).
|
|
1338
|
+
const statedMap = stated === null ? null : (all.find((p) => p.field === stated) ?? null);
|
|
1339
|
+
const tie: DeformDialTie | null =
|
|
1340
|
+
verdict !== 'settled'
|
|
1341
|
+
? null
|
|
1342
|
+
: {
|
|
1343
|
+
slider: slider.name,
|
|
1344
|
+
bone: boneName,
|
|
1345
|
+
drive: DIAL_PROPERTY[chosen.field],
|
|
1346
|
+
driveResponse: chosen.response,
|
|
1347
|
+
rivals: discovery.rivals.map((r) => ({ field: DIAL_PROPERTY[r.field], response: r.response })),
|
|
1348
|
+
};
|
|
1349
|
+
const dispute: DeformDialDispute | null =
|
|
1350
|
+
verdict !== 'disagreed'
|
|
1351
|
+
? null
|
|
1352
|
+
: {
|
|
1353
|
+
slider: slider.name,
|
|
1354
|
+
bone: boneName,
|
|
1355
|
+
stated: stated === null ? discovery.reader : DIAL_PROPERTY[stated],
|
|
1356
|
+
statedResponse: discovery.statedResponse,
|
|
1357
|
+
statedReach: statedMap === null ? null : dialReachOf(slider, statedMap),
|
|
1358
|
+
drive: DIAL_PROPERTY[chosen.field],
|
|
1359
|
+
driveResponse: chosen.response,
|
|
1360
|
+
driveReach: dialReachOf(slider, chosen),
|
|
1361
|
+
outside: [],
|
|
1362
|
+
};
|
|
1363
|
+
return {
|
|
1364
|
+
slider,
|
|
1365
|
+
reach: reach(discovery),
|
|
1366
|
+
tie,
|
|
1367
|
+
dispute,
|
|
1368
|
+
field: chosen.field,
|
|
1369
|
+
u0: chosen.u0,
|
|
1370
|
+
v0: chosen.v0,
|
|
1371
|
+
u1: chosen.u1,
|
|
1372
|
+
v1: chosen.v1,
|
|
1373
|
+
statedMap: dispute === null ? null : statedMap,
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The part of an animation's own `0..duration` one field of the driving bone can
|
|
1379
|
+
* select, or `null` when it can select none of it (issue #427).
|
|
1380
|
+
*
|
|
1381
|
+
* ⭐ **Closed form, and exact against the thing that decides.** `poseDial` refuses
|
|
1382
|
+
* outright a drive whose magnitude exceeds `DIAL_DRIVE_LIMIT`, and the two maps
|
|
1383
|
+
* between a field and a time are both affine — the probe's `field -> value`, and
|
|
1384
|
+
* `Slider.update`'s own `value -> time` inverted. So the times a *settable* value
|
|
1385
|
+
* of this field asks for are an interval, and its two ends are `±DIAL_DRIVE_LIMIT`
|
|
1386
|
+
* put through both. A field that moves the reading by nothing gives a single
|
|
1387
|
+
* point, which is the honest answer for it: the setup time and no other.
|
|
1388
|
+
*
|
|
1389
|
+
* ⚠️ It is a statement about what can be **asked for**, not about what the runtime
|
|
1390
|
+
* then does with it — `FromRotate`'s `[0, 360)` wrap can refuse a time this
|
|
1391
|
+
* interval contains. That is why the frames a disputed dial could not have posed
|
|
1392
|
+
* are POSED rather than read off here.
|
|
1393
|
+
*/
|
|
1394
|
+
function dialReachOf(slider: SliderData, map: DialProbe): DialSpan | null {
|
|
1395
|
+
const timeAt = (u: number): number => {
|
|
1396
|
+
const value = map.v0 + ((u - map.u0) * (map.v1 - map.v0)) / (map.u1 - map.u0);
|
|
1397
|
+
return slider.offset + (value - slider.property.offset) * slider.scale;
|
|
1398
|
+
};
|
|
1399
|
+
const ends = [timeAt(-DIAL_DRIVE_LIMIT), timeAt(DIAL_DRIVE_LIMIT)];
|
|
1400
|
+
if (!ends.every((t) => Number.isFinite(t))) return null;
|
|
1401
|
+
const lo = Math.max(0, Math.min(ends[0], ends[1]));
|
|
1402
|
+
const hi = Math.min(slider.animation.duration, Math.max(ends[0], ends[1]));
|
|
1403
|
+
return lo <= hi ? { lo, hi } : null;
|
|
1147
1404
|
}
|
|
1148
1405
|
|
|
1149
1406
|
/**
|
package/src/validate.ts
CHANGED
|
@@ -45,7 +45,14 @@ import {
|
|
|
45
45
|
// A19 needs the DECODED page, not its header, to measure one region's own
|
|
46
46
|
// rectangle on a shared page.
|
|
47
47
|
import { readPlate } from '../tools/plate.ts';
|
|
48
|
-
import {
|
|
48
|
+
import {
|
|
49
|
+
surveyDeformKeys,
|
|
50
|
+
unreachableWhy,
|
|
51
|
+
type DeformDialDispute,
|
|
52
|
+
type DeformDialTie,
|
|
53
|
+
type DeformReach,
|
|
54
|
+
type DialSpan,
|
|
55
|
+
} from './deformmeasure.ts';
|
|
49
56
|
import { colourTypeName, readPngInfo } from './png.ts';
|
|
50
57
|
import { CHANNELS_BY_KIND, KEY_TIME_EPSILON, walkTimelines } from './timelines.ts';
|
|
51
58
|
import type { RigInfo } from './types.ts';
|
|
@@ -238,6 +245,49 @@ function frameClause(reach: DeformReach): string {
|
|
|
238
245
|
return reach.kind === 'slider' ? ` (applied by slider "${reach.slider}", not played on a track)` : '';
|
|
239
246
|
}
|
|
240
247
|
|
|
248
|
+
/**
|
|
249
|
+
* A dial's reach as A39's stats line spells it, or `none` when it can select no
|
|
250
|
+
* part of its animation at all.
|
|
251
|
+
*
|
|
252
|
+
* Six decimals because a key time has six: a reach whose end is printed coarser
|
|
253
|
+
* than the times it is compared against cannot be read against them.
|
|
254
|
+
*/
|
|
255
|
+
function dialSpanText(span: DialSpan | null): string {
|
|
256
|
+
return span === null ? 'none' : `${span.lo.toFixed(6)}..${span.hi.toFixed(6)}s`;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* One disputed dial on A39's stats line — both answers, both reaches, and the
|
|
261
|
+
* frames the artifact's answer could not have posed (issue #427).
|
|
262
|
+
*
|
|
263
|
+
* ⭐ **`outside:` is always there, `none` included.** The comparison it reports is
|
|
264
|
+
* what decides whether a disagreement changed anything the survey measured, and a
|
|
265
|
+
* comparison that came out equal must not look like one nobody made. It is the
|
|
266
|
+
* difference between "both answers pose the same frames, so the disagreement is a
|
|
267
|
+
* fact about rigc and not about this rig" and "these key times were surveyed
|
|
268
|
+
* through a field the skeleton does not name and no settable value of the one it
|
|
269
|
+
* does reaches them".
|
|
270
|
+
*
|
|
271
|
+
* ⚠️ No spaces anywhere in it: the stats line is `k=v` pairs joined by spaces, and
|
|
272
|
+
* a value with a space in it turns one reading into two.
|
|
273
|
+
*/
|
|
274
|
+
function dialDisputeText(dispute: DeformDialDispute): string {
|
|
275
|
+
const stated = dispute.statedResponse === null ? 'unmeasured' : dispute.statedResponse.toExponential(3);
|
|
276
|
+
return (
|
|
277
|
+
`${dispute.slider}|artifact:${dispute.bone}.${dispute.stated}@${stated}` +
|
|
278
|
+
`|reaches:${dialSpanText(dispute.statedReach)}` +
|
|
279
|
+
`|probe:${dispute.bone}.${dispute.drive}@${dispute.driveResponse.toExponential(3)}` +
|
|
280
|
+
`|reaches:${dialSpanText(dispute.driveReach)}` +
|
|
281
|
+
`|outside:${dispute.outside.length === 0 ? 'none' : dispute.outside.map((t) => `${t.toFixed(6)}s`).join('+')}`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** One tied dial on the same line, in the shape that cannot be read as a dispute. */
|
|
286
|
+
function dialTieText(tie: DeformDialTie): string {
|
|
287
|
+
const rivals = tie.rivals.map((r) => `${tie.bone}.${r.field}@${r.response.toExponential(3)}`).join('+');
|
|
288
|
+
return `${tie.slider}|artifact:${tie.bone}.${tie.drive}@${tie.driveResponse.toExponential(3)}|tied:${rivals}`;
|
|
289
|
+
}
|
|
290
|
+
|
|
241
291
|
const FRAME = 1 / 60;
|
|
242
292
|
const STEP_FRAMES = 120;
|
|
243
293
|
|
|
@@ -1668,6 +1718,36 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
1668
1718
|
);
|
|
1669
1719
|
}
|
|
1670
1720
|
const survey = surveyDeformKeys(data, new Set(input.rig.deformMayFold));
|
|
1721
|
+
// 🚨 Which dial was turned, when rigc's two halves did not simply agree
|
|
1722
|
+
// about that — and BEFORE any of the returns below, because every one of
|
|
1723
|
+
// them is a run that posed frames through this dial (issue #427).
|
|
1724
|
+
//
|
|
1725
|
+
// The verdict used to live in `DeformReach.label`, which `explain` prints
|
|
1726
|
+
// and nothing else does, so a `build`-only run — the normal loop, and the
|
|
1727
|
+
// one an agent that cannot see the rig actually runs — never learned that
|
|
1728
|
+
// the artifact and the probe named different fields.
|
|
1729
|
+
//
|
|
1730
|
+
// ⛔ Not a refusal, and the measurement rather than taste is why (#427).
|
|
1731
|
+
// The frames this survey posed were each checked against `SliderPose.time`
|
|
1732
|
+
// by the runtime itself, so a disagreement cannot make it pose one that
|
|
1733
|
+
// does not happen; and the field it drives is the largest response the
|
|
1734
|
+
// probe found, so it cannot make it miss one that does. What a
|
|
1735
|
+
// disagreement CAN do is leave the rig naming a property no settable value
|
|
1736
|
+
// of turns far enough — which is what `outside` measures and what an
|
|
1737
|
+
// author can act on. A refusal would refuse a rig spine-core poses
|
|
1738
|
+
// correctly at every key, with no edit that would make it green.
|
|
1739
|
+
//
|
|
1740
|
+
// 🔒 A tie is not a disagreement and gets a line that cannot be read as
|
|
1741
|
+
// one: there the probe named no field, the artifact broke the tie, and the
|
|
1742
|
+
// parent-45° geometry that reaches it is legitimate.
|
|
1743
|
+
if (survey.dialTies.length) {
|
|
1744
|
+
stats.deformDialsTied = survey.dialTies.length;
|
|
1745
|
+
stats.deformDialTied = survey.dialTies.map(dialTieText).join(',');
|
|
1746
|
+
}
|
|
1747
|
+
if (survey.dialDisputes.length) {
|
|
1748
|
+
stats.deformDialsDisagreed = survey.dialDisputes.length;
|
|
1749
|
+
stats.deformDialDisagreed = survey.dialDisputes.map(dialDisputeText).join(',');
|
|
1750
|
+
}
|
|
1671
1751
|
/** A key this rule is refusing, by the triple that identifies it. */
|
|
1672
1752
|
const refusedKey = new Set<string>();
|
|
1673
1753
|
for (const key of survey.keys) {
|