spine-rigc 0.18.0 → 0.18.1
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 +98 -29
- package/package.json +1 -1
- package/src/compile.ts +81 -13
- package/src/deformmeasure.ts +325 -37
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,53 @@ sharing one of those overwrite each other whatever you write. A40 names that cas
|
|
|
1406
1406
|
separately, because the fix is different: key such a property from one slider
|
|
1407
1407
|
only, or move both edits into the single animation one slider applies.
|
|
1408
1408
|
|
|
1409
|
-
🚨 **A `rotate`-driven slider with `local: false`
|
|
1410
|
-
reads the bone's **world** rotation through
|
|
1411
|
-
`
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1409
|
+
🚨 **A `rotate`-driven slider with `local: false` has to stay inside the circle
|
|
1410
|
+
`[0, 360]`.** `local: false` reads the bone's **world** rotation through
|
|
1411
|
+
`FromRotate.value`, which is a `Math.atan2` — so `(−180, 180]` — with
|
|
1412
|
+
`if (value < 0) value += 360` on the end, and the `offsets` a slider hands it are
|
|
1413
|
+
all zero. `[0, 360)` is therefore the whole set of values that reader can ever
|
|
1414
|
+
return, and a range leaving it on either side is a wall:
|
|
1415
|
+
|
|
1416
|
+
- **Below 0°.** A yaw axis authored the natural way — neutral at 0°, range
|
|
1417
|
+
−15°..+15° — never sees a negative value: the bone at −15° arrives as 345°, and
|
|
1418
|
+
`time = to + (value − from) * scale` lands far past the animation. [measured]
|
|
1419
|
+
with `from: 0, to: 0.5, scale: 0.033333` over a 1 s animation the dial at −15°
|
|
1420
|
+
applies time **12.000 s**, so with `loop: false` the entire negative half of the
|
|
1421
|
+
axis holds the last frame — 15° of face jumping between −0.001° and 0°.
|
|
1422
|
+
- **Past 360°.** There is nothing above 360 to wrap *from*, so the value arrives
|
|
1423
|
+
360 **lower** instead. [measured] with `from: 300, to: 0, scale: 0.005` over a
|
|
1424
|
+
1 s animation the range is 300°..500°; the bone turned to 500° has a world
|
|
1425
|
+
rotation of 140°, is read as 140°, and applies time **−0.800 s** —
|
|
1426
|
+
`Math.max(0, time)` under `loop: false`. Swept through spine-core, that rig
|
|
1427
|
+
reaches only **0.000 s..0.2995 s of its own 1 s animation**: the top 140° of the
|
|
1428
|
+
dial does not merely fail to arrive, it arrives somewhere else.
|
|
1429
|
+
|
|
1430
|
+
Both are refused at compile, each with its own arithmetic in the message and its
|
|
1431
|
+
own repair — *"move the range so it does not cross 0°"* and *"move the range so it
|
|
1432
|
+
does not run past 360°"*.
|
|
1433
|
+
|
|
1434
|
+
⭐ **A range ending exactly on 360° is legal**, and that is the whole turn: a
|
|
1435
|
+
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 exactly one value — its own
|
|
1437
|
+
supremum — and that value is not a dial position: a bone at 360° *is* a bone at
|
|
1438
|
+
0°, and [measured] it poses the skeleton to within **4e-7°** of it, an `atan2`
|
|
1439
|
+
artefact rather than a frame. Swept at 0.1° over the circle, `from: 0,
|
|
1440
|
+
scale: 0.0025` on a 0.9 s animation lands every reading within **1.7e-8 s** of the
|
|
1441
|
+
time the mapping asks for; on `loop: true` the endpoint is not even distinct,
|
|
1442
|
+
closing on **0.900000 s** exactly. Use `loop: true` for a dial that really does go
|
|
1443
|
+
round, so the seam at 0°/360° is the wrap it is meant to be.
|
|
1444
|
+
|
|
1445
|
+
⇒ **`local: true` is the form a face axis wants**, and it is the first repair both
|
|
1446
|
+
messages name. It reads `source.rotation` signed and unwrapped, which for a dial
|
|
1447
|
+
bone is the number you authored — 500° included — because `FromRotate`'s wrap is
|
|
1448
|
+
not on that path at all. The alternative each refusal leaves open is to move the
|
|
1449
|
+
range inside the circle (a neutral at 180°, say), which is the only form
|
|
1450
|
+
`local: false` can express.
|
|
1451
|
+
|
|
1452
|
+
⚠️ **An artifact can still carry a dead range** — one exported from the editor,
|
|
1453
|
+
hand-edited, or built by an older rigc. `A39` reports that from the artifact side
|
|
1454
|
+
as a key at a time no dial selects (§4.11.4); the compile refusal above is what
|
|
1455
|
+
stops a rig spec in this repository from producing one.
|
|
1430
1456
|
|
|
1431
1457
|
📌 **A slider's animation is measured in the slider's own frame** — `A39` inverts
|
|
1432
1458
|
the mapping above and drives the bone to the value it names, rather than playing
|
|
@@ -2519,7 +2545,7 @@ deform (what each key does to the geometry — figures with names, never a bar;
|
|
|
2519
2545
|
| Row | What it is |
|
|
2520
2546
|
| --- | --- |
|
|
2521
2547
|
| 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
|
|
2548
|
+
| `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
2549
|
| `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
2550
|
| `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
2551
|
| `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 |
|
|
@@ -2715,12 +2741,13 @@ dial and not reached at all through another.
|
|
|
2715
2741
|
[#405](https://github.com/firejune/rigc/issues/405)'s wrap: `FromRotate.value`
|
|
2716
2742
|
under `local: false` is an `atan2` ending `if (value < 0) value += 360`, so
|
|
2717
2743
|
**`[0, 360)` is the whole of its range** and a mapping needing anything outside
|
|
2718
|
-
it selects nothing. 🚨
|
|
2719
|
-
(§3.5.2)
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2744
|
+
it selects nothing. 🚨 A **rig spec** can no longer ask for one — the compiler
|
|
2745
|
+
refuses both ends of that circle (§3.5.2), the low one since #405 and the high
|
|
2746
|
+
one since [#417](https://github.com/firejune/rigc/issues/417) — but an
|
|
2747
|
+
**artifact** can, because it may have come from the editor, from a hand edit or
|
|
2748
|
+
from an older rigc, and this is the surface that reads what actually shipped.
|
|
2749
|
+
`A39` measures the frame the runtime *does* land on, leaves the key out of
|
|
2750
|
+
`deformKeysMeasured`, and names it:
|
|
2724
2751
|
|
|
2725
2752
|
```
|
|
2726
2753
|
deformKeysUnreachable=2 deformUnreachable=turn/head/head#0@10.800000,turn/head/head#1@11.300000
|
|
@@ -2730,6 +2757,47 @@ deformSpansNotScanned=2
|
|
|
2730
2757
|
— the `@` is the time the runtime lands on instead — with the `DEFORM` block
|
|
2731
2758
|
giving the whole sentence and the spans those keys bound left unscanned rather
|
|
2732
2759
|
than scanned over two poses of some other time.
|
|
2760
|
+
- 🚨 **A dial past `±16777216` is named, not printed.** The drive is a number you
|
|
2761
|
+
are meant to act on — *set `knob.scaleX` to this* — and past 2²⁴ a float32 no
|
|
2762
|
+
longer separates consecutive integers, so a figure up there is not one anybody
|
|
2763
|
+
can set and read back. `A39` leaves the bone at its setup value instead, counts
|
|
2764
|
+
the key as unreachable, and says so with both numbers:
|
|
2765
|
+
|
|
2766
|
+
```
|
|
2767
|
+
unreachable A39 gates nothing here: the dial for t=1s is out of bounds: slider "dial" maps
|
|
2768
|
+
that time back to 17000000.000000, which needs knob.rotate at 1.7000e+7 — past the ±16777216
|
|
2769
|
+
this solve will drive a bone field to. …
|
|
2770
|
+
```
|
|
2771
|
+
|
|
2772
|
+
The usual cause is a `from` or a `scale` that puts the animation's own range out
|
|
2773
|
+
of reach; check the two against `value = from + (time − to) / scale` above.
|
|
2774
|
+
|
|
2775
|
+
#### Which property the `frame` line names
|
|
2776
|
+
|
|
2777
|
+
The name on the `frame` line — `off yaw_dial.rotate (local)` — is read off the
|
|
2778
|
+
**emitted skeleton**: it is whichever `FromProperty` the parser built out of your
|
|
2779
|
+
`property` field, so it is the word you wrote. rigc *also* probes all six local
|
|
2780
|
+
fields of the driving bone to find which one moves the reading, because under
|
|
2781
|
+
`local: false` the reader goes through the world transform and only a measurement
|
|
2782
|
+
can say what drives it there. **The two answers must agree, and when they do not
|
|
2783
|
+
the line says so rather than picking one silently.**
|
|
2784
|
+
|
|
2785
|
+
- Under `local: true` they always agree and the line carries nothing extra.
|
|
2786
|
+
- Under `local: false` they can legitimately differ, because the reading is a
|
|
2787
|
+
*world* one. A bone whose parent is turned 90° has a world x that its own local
|
|
2788
|
+
`x` does not move at all and its local `y` moves entirely, so the line reads
|
|
2789
|
+
`off knob.x (world), driven through knob.y — the probe moves the reading by
|
|
2790
|
+
knob.y 1.000e+0 while the skeleton says the reader is knob.x, which moves it by
|
|
2791
|
+
2.321e-8. The two disagree and both are reported…`, and the `bone local` figure
|
|
2792
|
+
beside the dial names its field (`bone local y 398.999991`).
|
|
2793
|
+
- At 45° the two fields move it *equally*, which no ranking can separate; the line
|
|
2794
|
+
says the probe did not settle it and that the skeleton's own reader broke the
|
|
2795
|
+
tie. ⛔ Neither case is a refusal and neither is guessed past — an ambiguous
|
|
2796
|
+
discovery is a thing to report.
|
|
2797
|
+
|
|
2798
|
+
None of this needs anything from you unless a `frame` line carries one of those
|
|
2799
|
+
clauses. If one does, it is telling you the dial bone's parent transform is doing
|
|
2800
|
+
something you may not have intended.
|
|
2733
2801
|
|
|
2734
2802
|
⚠️ **What the artifact cannot say, and rigc therefore does not:** whether a
|
|
2735
2803
|
slider's animation is *also* played on a track somewhere. Nothing in skeleton data
|
|
@@ -2905,7 +2973,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
|
|
|
2905
2973
|
| `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
2974
|
| `rig constraint "X": declares both a "bone" and "time"` | §3.5.2 — `bone` picks the model and `time` belongs to the other one |
|
|
2907
2975
|
| `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°
|
|
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)` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
|
|
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)` 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 the only value it misses is a supremum no dial can be parked at separately |
|
|
2909
2978
|
| `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
2979
|
| `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
2980
|
| `skin "S": uses the long form … and also has a key "X"` | §3.4.1 — move the slot inside `attachments` |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
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,43 @@ 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, and which way the reader
|
|
3544
|
+
// moves it: below 0° the wrap ADDS 360, past 360° there is nothing above
|
|
3545
|
+
// to wrap from and the value arrives 360 LOWER instead. One record, so
|
|
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.
|
|
3548
|
+
//
|
|
3549
|
+
// The two tests ARE each other's mirror, and that is deliberate: each
|
|
3550
|
+
// gives its own boundary — 0° and 360°, the two values an author aims at
|
|
3551
|
+
// — the same outward slack, so only a range that genuinely leaves the
|
|
3552
|
+
// closed circle `[0, 360]` is refused.
|
|
3553
|
+
const dead =
|
|
3554
|
+
lowest < -SLIDER_WRAP_SLACK
|
|
3555
|
+
? {
|
|
3556
|
+
end: lowest,
|
|
3557
|
+
readAs: lowest + 360,
|
|
3558
|
+
side: 'below 0°',
|
|
3559
|
+
repair: 'move the range so it does not cross 0°',
|
|
3560
|
+
}
|
|
3561
|
+
: highest > 360 + SLIDER_WRAP_SLACK
|
|
3562
|
+
? {
|
|
3563
|
+
end: highest,
|
|
3564
|
+
readAs: highest - 360,
|
|
3565
|
+
side: 'past 360°',
|
|
3566
|
+
repair: 'move the range so it does not run past 360°',
|
|
3567
|
+
}
|
|
3568
|
+
: null;
|
|
3569
|
+
if (dead !== null) {
|
|
3570
|
+
const lands = toTime + (dead.readAs - fromValue) * perUnit;
|
|
3503
3571
|
throw new CompileError(
|
|
3504
3572
|
`${where}: drives off bone "${String(spec.bone)}" rotate with "local": false, and the driving values ` +
|
|
3505
3573
|
`that reach animation "${animation}" (0s..${duration}s) run from ${lowest.toFixed(3)}° to ${highest.toFixed(3)}°. ` +
|
|
3506
3574
|
'A world rotation is read through `FromRotate.value`, which ends `if (value < 0) value += 360`, so the bone ' +
|
|
3507
|
-
`at ${
|
|
3575
|
+
`at ${dead.end.toFixed(3)}° is read as ${dead.readAs.toFixed(3)}° and maps to time ${lands.toFixed(3)}s — outside ` +
|
|
3508
3576
|
`the animation's ${duration}s. With "loop": false that is \`Math.max(0, time)\` holding the last frame; with ` +
|
|
3509
|
-
|
|
3577
|
+
`"loop": true it wraps to some other frame. Either way the whole part of the range ${dead.side} is dead and ` +
|
|
3510
3578
|
'nothing at runtime reports it. Add `"local": true` to read the bone\'s own rotation signed and unwrapped — ' +
|
|
3511
|
-
|
|
3579
|
+
`that is the form a face axis wants — or ${dead.repair}.`,
|
|
3512
3580
|
);
|
|
3513
3581
|
}
|
|
3514
3582
|
}
|
package/src/deformmeasure.ts
CHANGED
|
@@ -95,6 +95,13 @@ import {
|
|
|
95
95
|
AtlasAttachmentLoader,
|
|
96
96
|
type Bone,
|
|
97
97
|
DeformTimeline,
|
|
98
|
+
FromProperty,
|
|
99
|
+
FromRotate,
|
|
100
|
+
FromScaleX,
|
|
101
|
+
FromScaleY,
|
|
102
|
+
FromShearY,
|
|
103
|
+
FromX,
|
|
104
|
+
FromY,
|
|
98
105
|
MeshAttachment,
|
|
99
106
|
Physics,
|
|
100
107
|
Skeleton,
|
|
@@ -298,12 +305,26 @@ export interface DeformReach {
|
|
|
298
305
|
/** The slider's driving bone. `null` on the track, and on a bone-less slider. */
|
|
299
306
|
bone: string | null;
|
|
300
307
|
/**
|
|
301
|
-
* The transform property the slider reads off that bone, as
|
|
302
|
-
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
308
|
+
* The transform property the slider reads off that bone, as a rig spec spells
|
|
309
|
+
* it (`rotate`, `x`, `y`, `scaleX`, …), or `null` when there is no bone.
|
|
310
|
+
*
|
|
311
|
+
* 🚨 Off the **artifact** since issue #419 — spine-core's own `FromProperty`
|
|
312
|
+
* subclass, which is what the parser built out of the rig spec's `property`
|
|
313
|
+
* field. It used to be whichever local field the probe below found first, and
|
|
314
|
+
* float noise makes `rotation` move a world `sqrt(a² + c²)` reading, so every
|
|
315
|
+
* world `scaleX` / `scaleY` / `shearY` slider was reported as `rotate`.
|
|
305
316
|
*/
|
|
306
317
|
property: string | null;
|
|
318
|
+
/**
|
|
319
|
+
* The bone field the solve actually drives, when that is **not** the one
|
|
320
|
+
* `property` names; `null` when they are the same, which is every rig that has
|
|
321
|
+
* a plain parent.
|
|
322
|
+
*
|
|
323
|
+
* They come apart under `local: false`, where the reader goes through the world
|
|
324
|
+
* transform: a `FromX` slider on a bone whose parent is at 90° reads a world x
|
|
325
|
+
* that local `x` does not move at all and local `y` moves entirely.
|
|
326
|
+
*/
|
|
327
|
+
drive: string | null;
|
|
307
328
|
/** `SliderData.local` — whether the property is read local or world. */
|
|
308
329
|
local: boolean;
|
|
309
330
|
/** The clause the `DEFORM` block and A39's stats line print. */
|
|
@@ -316,6 +337,7 @@ const TRACK_REACH: DeformReach = {
|
|
|
316
337
|
slider: null,
|
|
317
338
|
bone: null,
|
|
318
339
|
property: null,
|
|
340
|
+
drive: null,
|
|
319
341
|
local: false,
|
|
320
342
|
label: 'played on a track',
|
|
321
343
|
};
|
|
@@ -343,6 +365,17 @@ export interface DeformDial {
|
|
|
343
365
|
* `local: false`, where the reader goes through the world transform.
|
|
344
366
|
*/
|
|
345
367
|
driven: number;
|
|
368
|
+
/**
|
|
369
|
+
* The drive the mapping asked for when it was past `DIAL_DRIVE_LIMIT` and the
|
|
370
|
+
* bone was therefore stopped at the bound instead — `null` on every dial that
|
|
371
|
+
* stayed inside it, which is every correct rig (issue #419).
|
|
372
|
+
*
|
|
373
|
+
* 🚨 The runaway made visible. Before #419 a world scale slider was solved
|
|
374
|
+
* through `rotation`, whose response to it is float noise, and the drive ran to
|
|
375
|
+
* 4.9e9 with nothing saying so. Now the ask is carried here, the bone is not
|
|
376
|
+
* posed there, and the key is reported unreachable naming both numbers.
|
|
377
|
+
*/
|
|
378
|
+
beyondLimit: number | null;
|
|
346
379
|
/** `SliderPose.time` the runtime then computed, read off the posed skeleton. */
|
|
347
380
|
applied: number;
|
|
348
381
|
/** What `Slider.update` would have stored for this key's own time. */
|
|
@@ -351,9 +384,11 @@ export interface DeformDial {
|
|
|
351
384
|
* `applied` is not `wanted`: **no dial value selects this key's time.**
|
|
352
385
|
*
|
|
353
386
|
* The reachable one, and it is not hypothetical: `FromRotate.value` ends
|
|
354
|
-
* `if (value < 0) value += 360`, so
|
|
355
|
-
*
|
|
356
|
-
* arrives 360° away
|
|
387
|
+
* `if (value < 0) value += 360` over an `atan2`, so `[0, 360)` is the whole of
|
|
388
|
+
* what a `rotate` slider reading a WORLD rotation can be driven to, and
|
|
389
|
+
* everything the inversion asks for outside it arrives 360° away — below 0°
|
|
390
|
+
* (issue #405) and at or above 360° (issue #417) alike. `Math.max(0, time)` is
|
|
391
|
+
* the other.
|
|
357
392
|
*
|
|
358
393
|
* ⚠️ A key like that is measured — at the frame the runtime does land on, which
|
|
359
394
|
* the report names — and then left OUT of the gate's counts, because the
|
|
@@ -577,15 +612,33 @@ export function unreachableWhy(key: DeformKeyMeasure): string {
|
|
|
577
612
|
key.reach.bone === null
|
|
578
613
|
? `slider "${key.reach.slider}"'s own time`
|
|
579
614
|
: `${key.reach.bone}.${key.reach.property} (${key.reach.local ? 'local' : 'world'})`;
|
|
615
|
+
// 🚨 The runaway, named with both numbers instead of printed as a dial (issue
|
|
616
|
+
// #419). This comes first because it is a different fact from the one below: the
|
|
617
|
+
// bone was never put where the mapping asked, so what the runtime applied is the
|
|
618
|
+
// clamp's time and says nothing about whether the key is otherwise reachable.
|
|
619
|
+
if (dial.beyondLimit !== null) {
|
|
620
|
+
const field = key.reach.drive ?? key.reach.property;
|
|
621
|
+
return (
|
|
622
|
+
`the dial for t=${key.time}s is out of bounds: slider "${key.reach.slider}" maps that time back to ` +
|
|
623
|
+
`${dial.value.toFixed(6)}, which needs ${key.reach.bone}.${field} at ${dial.beyondLimit.toExponential(4)} — ` +
|
|
624
|
+
`past the ±${DIAL_DRIVE_LIMIT} this solve will drive a bone field to. Past 2^24 a float32 no longer holds ` +
|
|
625
|
+
'two consecutive integers apart, so a figure up there is not a value anybody can set and read back. The ' +
|
|
626
|
+
'bone was left at its setup value rather than driven there, so what was posed is the setup frame and not ' +
|
|
627
|
+
`the one that mapping names — the runtime applied ${dial.applied.toFixed(6)}s against the ` +
|
|
628
|
+
`${dial.wanted.toFixed(6)}s wanted`
|
|
629
|
+
);
|
|
630
|
+
}
|
|
580
631
|
// The one reader that cannot produce a value it is asked for, named where an
|
|
581
632
|
// author will meet it: a WORLD rotation is an `atan2` ending in
|
|
582
633
|
// `if (value < 0) value += 360`, so **[0, 360) is the whole of its range** and
|
|
583
634
|
// `"local": true` is the fix (issue #405).
|
|
584
635
|
//
|
|
585
|
-
// ⚠️ Both ends, not just the low one
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
636
|
+
// ⚠️ Both ends, not just the low one — a range running past 360° dies in
|
|
637
|
+
// exactly the way one dipping below 0° does. The compiler refuses both since
|
|
638
|
+
// issue #417 (it refused only the low end when #405 landed), so a rig spec in
|
|
639
|
+
// this repository cannot ask for either. This clause is still not redundant:
|
|
640
|
+
// an artifact reaching the gate from the editor, a hand edit or an older rigc
|
|
641
|
+
// can carry a dead range, and the artifact is what this file reads.
|
|
589
642
|
const wrap =
|
|
590
643
|
key.reach.property === 'rotate' && !key.reach.local && (dial.value < 0 || dial.value >= 360)
|
|
591
644
|
? '. A world rotation is read through `FromRotate.value`, an `atan2` ending `if (value < 0) value += 360`, ' +
|
|
@@ -789,6 +842,70 @@ function dialStep(field: DialField): number {
|
|
|
789
842
|
return field === 'scaleX' || field === 'scaleY' ? 0.25 : 1;
|
|
790
843
|
}
|
|
791
844
|
|
|
845
|
+
/**
|
|
846
|
+
* Which `BonePose` field spine-core's own reader is named for — off the ARTIFACT,
|
|
847
|
+
* which is the second, independent answer the probe below is checked against
|
|
848
|
+
* (issue #419).
|
|
849
|
+
*
|
|
850
|
+
* ⭐ This is an identity, not a dispatch table. `SliderData.property` is one of
|
|
851
|
+
* six classes the parser built out of the rig spec's `property` field, so asking
|
|
852
|
+
* which class it is asks the file what the author wrote; it says nothing about
|
|
853
|
+
* *how* the value is computed, which is the part #407 was careful never to
|
|
854
|
+
* transcribe and which the probe still measures.
|
|
855
|
+
*
|
|
856
|
+
* `null` for a reader this file does not know — unreachable against spine-core
|
|
857
|
+
* 4.3, which has exactly these six, and deliberately not folded into a default:
|
|
858
|
+
* a seventh reader in some later runtime must be **named** in the report, not
|
|
859
|
+
* silently mapped to whatever the probe happened to find.
|
|
860
|
+
*/
|
|
861
|
+
function readerField(property: FromProperty): DialField | null {
|
|
862
|
+
if (property instanceof FromRotate) return 'rotation';
|
|
863
|
+
if (property instanceof FromX) return 'x';
|
|
864
|
+
if (property instanceof FromY) return 'y';
|
|
865
|
+
if (property instanceof FromScaleX) return 'scaleX';
|
|
866
|
+
if (property instanceof FromScaleY) return 'scaleY';
|
|
867
|
+
if (property instanceof FromShearY) return 'shearY';
|
|
868
|
+
return null;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* How the artifact's answer and the probe's answer settled (issue #419).
|
|
873
|
+
*
|
|
874
|
+
* - `agreed` — one field responds decisively and it is the reader's own. Every
|
|
875
|
+
* rig in the gallery and every `local: true` slider.
|
|
876
|
+
* - `settled` — the probe was **not** decisive (two or more fields within
|
|
877
|
+
* `DIAL_PROBE_MARGIN` of each other) and the reader's own field is one of them,
|
|
878
|
+
* so the artifact broke the tie. Real: a `FromX` slider under `local: false` on
|
|
879
|
+
* a bone whose parent is at 45° is driven exactly equally by local `x` and
|
|
880
|
+
* local `y`, and letting list order decide between them would be the same
|
|
881
|
+
* silent arbitrariness #419 was filed on.
|
|
882
|
+
* - `disagreed` — the field that moves the reading is not the reader's own field.
|
|
883
|
+
* Also real: the same slider with the parent at 90° is moved by local `y` alone
|
|
884
|
+
* and not at all by local `x`. The probe wins the drive, because it is the one
|
|
885
|
+
* that measured something, and the disagreement is printed rather than resolved.
|
|
886
|
+
*/
|
|
887
|
+
type DialVerdict = 'agreed' | 'settled' | 'disagreed';
|
|
888
|
+
|
|
889
|
+
/** The two answers, what they were, and how they settled. */
|
|
890
|
+
interface DialDiscovery {
|
|
891
|
+
/** The reader's own field, off the artifact. `null` on a reader not known here. */
|
|
892
|
+
stated: DialField | null;
|
|
893
|
+
/** What the artifact's reader is called, for a report that has to name it. */
|
|
894
|
+
reader: string;
|
|
895
|
+
/** The field the solve drives. Always one the probe measured a response on. */
|
|
896
|
+
drive: DialField;
|
|
897
|
+
/** What one step of that field moved the reading by. */
|
|
898
|
+
driveResponse: number;
|
|
899
|
+
/**
|
|
900
|
+
* The other fields inside `DIAL_PROBE_MARGIN` of the winner — the ones that
|
|
901
|
+
* stopped the probe being decisive. Empty whenever it was.
|
|
902
|
+
*/
|
|
903
|
+
rivals: Array<{ field: DialField; response: number }>;
|
|
904
|
+
/** What the reader's OWN field responded, when it is not the one driven. */
|
|
905
|
+
statedResponse: number | null;
|
|
906
|
+
verdict: DialVerdict;
|
|
907
|
+
}
|
|
908
|
+
|
|
792
909
|
/**
|
|
793
910
|
* A slider whose animation is being posed, with everything the inversion needs
|
|
794
911
|
* measured off the runtime rather than assumed.
|
|
@@ -808,6 +925,19 @@ interface DialPlan {
|
|
|
808
925
|
v1: number;
|
|
809
926
|
}
|
|
810
927
|
|
|
928
|
+
/**
|
|
929
|
+
* One field's probe: how far one step of it moved the reading, and the two points
|
|
930
|
+
* of the affine map that step traced.
|
|
931
|
+
*/
|
|
932
|
+
interface DialProbe {
|
|
933
|
+
field: DialField;
|
|
934
|
+
response: number;
|
|
935
|
+
u0: number;
|
|
936
|
+
v0: number;
|
|
937
|
+
u1: number;
|
|
938
|
+
v1: number;
|
|
939
|
+
}
|
|
940
|
+
|
|
811
941
|
/**
|
|
812
942
|
* How near the wanted time the runtime has to land before the dial is called
|
|
813
943
|
* good, in seconds.
|
|
@@ -823,6 +953,51 @@ const DIAL_TIME_EPSILON = 1e-6;
|
|
|
823
953
|
/** How many secant steps the solve takes before it calls a time unreachable. */
|
|
824
954
|
const DIAL_SOLVE_STEPS = 4;
|
|
825
955
|
|
|
956
|
+
/**
|
|
957
|
+
* How far the largest probe response has to clear the runner-up before the probe
|
|
958
|
+
* alone is allowed to name the field the solve drives (issue #419).
|
|
959
|
+
*
|
|
960
|
+
* ⭐ **A ratio, so it carries no units and no fixture's scale.** What it separates
|
|
961
|
+
* was measured rather than assumed, on this file's own probe:
|
|
962
|
+
*
|
|
963
|
+
* | reading | field | response to one step | ratio to the winner |
|
|
964
|
+
* | --- | --- | --- | --- |
|
|
965
|
+
* | `FromScaleX`, world, plain parent | `scaleX` | 2.5e-1 | — |
|
|
966
|
+
* | | `rotation` | 4.0e-10 (float noise in `sqrt(a²+c²)`) | 6.2e8 |
|
|
967
|
+
* | `FromX`, world, parent at 90° | `y` | 1.0 | — |
|
|
968
|
+
* | | `x` | 4.6e-8 (float noise) | 2.2e7 |
|
|
969
|
+
* | `FromScaleX`, world, parent scaled 2×1 | `scaleX` | 5.0e-1 | — |
|
|
970
|
+
* | | `rotation` | 2.3e-4 (**real**, not noise) | 2.2e3 |
|
|
971
|
+
* | `FromX`, world, parent at 45° | `x` | 7.07e-1 | — |
|
|
972
|
+
* | | `y` | 7.07e-1 (**real**, and exactly equal) | 1.0 |
|
|
973
|
+
*
|
|
974
|
+
* So 1e3 sits between the smallest genuine secondary dependency measured (2.2e3)
|
|
975
|
+
* and the largest float-noise ratio (2.2e7), with four orders of headroom on the
|
|
976
|
+
* side that matters. ⚠️ It is not a noise threshold and must not be read as one:
|
|
977
|
+
* a rig with a heavier parent scale pushes that 2.2e3 down through it, and what
|
|
978
|
+
* happens then is not a wrong answer but an *ambiguous* one — which this file
|
|
979
|
+
* reports and the artifact settles, below.
|
|
980
|
+
*/
|
|
981
|
+
const DIAL_PROBE_MARGIN = 1e3;
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* The largest magnitude the solve will drive a bone field to (issue #419).
|
|
985
|
+
*
|
|
986
|
+
* 🚨 **This is the bound that replaces "gave up at 4.9e9".** A dial figure is an
|
|
987
|
+
* instruction — *set `knob.scaleX` to this* — and past 2²⁴ a float32 cannot hold
|
|
988
|
+
* two consecutive integers apart, so a number up there is not one an author can
|
|
989
|
+
* set and read back; the emitted skeleton, which rounds to six decimals, has lost
|
|
990
|
+
* the precision long before. A drive the mapping asks for beyond this is
|
|
991
|
+
* therefore **not posed**: the bone stops at the bound, the runtime lands on some
|
|
992
|
+
* other time, and the key is reported unreachable with the ask and the bound both
|
|
993
|
+
* named — never printed as if it were advice.
|
|
994
|
+
*
|
|
995
|
+
* The runaway #419 was filed on reached 1.2e9, 2.5e9 and 4.9e9, all two orders
|
|
996
|
+
* above this, and every dial `gallery/look` and the DW fixtures ask for is under
|
|
997
|
+
* 200.
|
|
998
|
+
*/
|
|
999
|
+
const DIAL_DRIVE_LIMIT = 2 ** 24;
|
|
1000
|
+
|
|
826
1001
|
/**
|
|
827
1002
|
* Every way each animation is reached, keyed by animation name.
|
|
828
1003
|
*
|
|
@@ -867,27 +1042,66 @@ function sliderOn(skeleton: Skeleton, data: SliderData): Slider | null {
|
|
|
867
1042
|
* Find which local field of the driving bone moves a slider's time, and read the
|
|
868
1043
|
* affine map from that field to the property value off two probes.
|
|
869
1044
|
*
|
|
870
|
-
* ⭐ **Probed rather than tabulated.**
|
|
871
|
-
*
|
|
872
|
-
*
|
|
873
|
-
* runtime's
|
|
874
|
-
*
|
|
875
|
-
* the same all-zero offsets — say it instead, and every one of the six
|
|
876
|
-
* affine in its own field at a fixed parent pose, so two points
|
|
1045
|
+
* ⭐ **Probed rather than tabulated.** How a `FromProperty` turns a bone into a
|
|
1046
|
+
* number is spine-core's business, and under `local: false` it goes through the
|
|
1047
|
+
* world transform — so a table of that here would be a second copy of the
|
|
1048
|
+
* runtime's arithmetic AND a claim about parents this file has no business
|
|
1049
|
+
* making. Calls to `data.property.value` — the same call `Slider.update` makes,
|
|
1050
|
+
* with the same all-zero offsets — say it instead, and every one of the six
|
|
1051
|
+
* readers is affine in its own field at a fixed parent pose, so two points of the
|
|
1052
|
+
* winner are the whole map.
|
|
1053
|
+
*
|
|
1054
|
+
* 🚨 **What the probe is NOT allowed to do is stop at the first field that
|
|
1055
|
+
* moves** (issue #419). A world scale is read as `sqrt(a² + c²)`; perturbing
|
|
1056
|
+
* `rotation` changes `a` and `c` by amounts that do not cancel in floating point,
|
|
1057
|
+
* `rotation` is probed first, and every world `scaleX` / `scaleY` / `shearY`
|
|
1058
|
+
* slider was therefore reported as `rotate` — and then solved through a field
|
|
1059
|
+
* whose response to it is 4e-10, which is how a drive reached 4.9e9. So:
|
|
1060
|
+
*
|
|
1061
|
+
* 1. **every** field is probed and they are ranked by response;
|
|
1062
|
+
* 2. the winner has to clear the runner-up by `DIAL_PROBE_MARGIN` to be called
|
|
1063
|
+
* decisive — a tie is a real thing (a `FromX` slider on a bone whose parent
|
|
1064
|
+
* is at 45° is moved exactly equally by local `x` and local `y`) and letting
|
|
1065
|
+
* `DIAL_FIELDS`'s order settle it silently is the same defect one step over;
|
|
1066
|
+
* 3. and the answer is cross-checked against the **artifact** — spine-core's own
|
|
1067
|
+
* reader class, which is what the parser built out of the rig spec's
|
|
1068
|
+
* `property`. That is what the report names, it settles a tie the probe could
|
|
1069
|
+
* not, and where the two genuinely differ the report says so by name.
|
|
1070
|
+
*
|
|
1071
|
+
* ⛔ Nothing here falls back to `rotation`, or to any field, when the search does
|
|
1072
|
+
* not settle. Two answers that disagree are two answers, printed.
|
|
877
1073
|
*/
|
|
878
1074
|
function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
:
|
|
889
|
-
|
|
890
|
-
|
|
1075
|
+
const boneName = slider.bone?.name ?? '?';
|
|
1076
|
+
const where = slider.local ? ' (local)' : ' (world)';
|
|
1077
|
+
const reach = (discovery: DialDiscovery | null): DeformReach => {
|
|
1078
|
+
if (discovery === null) {
|
|
1079
|
+
return {
|
|
1080
|
+
kind: 'slider',
|
|
1081
|
+
slider: slider.name,
|
|
1082
|
+
bone: slider.bone?.name ?? null,
|
|
1083
|
+
property: null,
|
|
1084
|
+
drive: null,
|
|
1085
|
+
local: slider.local,
|
|
1086
|
+
label: `applied by slider "${slider.name}" at its own time`,
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
1089
|
+
// The name comes off the artifact whenever the artifact has one. A reader
|
|
1090
|
+
// this file does not know is named by its class rather than by a guess.
|
|
1091
|
+
const property = discovery.stated === null ? discovery.reader : DIAL_PROPERTY[discovery.stated];
|
|
1092
|
+
const drive = discovery.stated === discovery.drive ? null : DIAL_PROPERTY[discovery.drive];
|
|
1093
|
+
return {
|
|
1094
|
+
kind: 'slider',
|
|
1095
|
+
slider: slider.name,
|
|
1096
|
+
bone: slider.bone?.name ?? null,
|
|
1097
|
+
property,
|
|
1098
|
+
drive,
|
|
1099
|
+
local: slider.local,
|
|
1100
|
+
label:
|
|
1101
|
+
`applied by slider "${slider.name}" off ${boneName}.${property}${where}` +
|
|
1102
|
+
dialDiscoveryClause(discovery, boneName),
|
|
1103
|
+
};
|
|
1104
|
+
};
|
|
891
1105
|
// The bone-less form: `Slider.update` leaves `p.time` alone, so the dial IS the
|
|
892
1106
|
// pose value and the map is the identity.
|
|
893
1107
|
if (slider.bone === null) return { slider, reach: reach(null), field: null, u0: 0, v0: 0, u1: 1, v1: 1 };
|
|
@@ -895,18 +1109,71 @@ function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
|
|
|
895
1109
|
const instance = sliderOn(skeleton, slider);
|
|
896
1110
|
const bone = instance?.bone ?? null;
|
|
897
1111
|
if (instance === null || bone === null) return null;
|
|
1112
|
+
const probes: DialProbe[] = [];
|
|
898
1113
|
for (const field of DIAL_FIELDS) {
|
|
899
1114
|
const step = dialStep(field);
|
|
900
1115
|
skeleton.setupPose();
|
|
901
1116
|
const base = bone.pose[field];
|
|
902
1117
|
const v0 = dialValue(skeleton, slider, bone, field, base);
|
|
903
1118
|
const v1 = dialValue(skeleton, slider, bone, field, base + step);
|
|
904
|
-
|
|
905
|
-
|
|
1119
|
+
const response = Math.abs(v1 - v0);
|
|
1120
|
+
if (!Number.isFinite(response) || response === 0) continue;
|
|
1121
|
+
probes.push({ field, response, u0: base, v0, u1: base + step, v1 });
|
|
906
1122
|
}
|
|
907
1123
|
// Nothing moves it: a bone another constraint pins, or a reader that cannot see
|
|
908
1124
|
// this bone at all. A37 owns the `scale: 0` shape of the same silence.
|
|
909
|
-
return null;
|
|
1125
|
+
if (probes.length === 0) return null;
|
|
1126
|
+
// ⚠️ A stable sort on a strict comparison, so an exact tie keeps `DIAL_FIELDS`'s
|
|
1127
|
+
// order and the tie is DETECTED below rather than decided here. A comparator
|
|
1128
|
+
// that broke ties would put the arbitrary choice back where nothing sees it.
|
|
1129
|
+
probes.sort((a, b) => b.response - a.response);
|
|
1130
|
+
const winner = probes[0];
|
|
1131
|
+
const stated = readerField(slider.property);
|
|
1132
|
+
const leaders = probes.filter((p) => p.response * DIAL_PROBE_MARGIN >= winner.response);
|
|
1133
|
+
const decisive = leaders.length === 1;
|
|
1134
|
+
const chosen = decisive || stated === null ? winner : (leaders.find((p) => p.field === stated) ?? winner);
|
|
1135
|
+
const verdict: DialVerdict = chosen.field === stated ? (decisive ? 'agreed' : 'settled') : 'disagreed';
|
|
1136
|
+
const discovery: DialDiscovery = {
|
|
1137
|
+
stated,
|
|
1138
|
+
reader: slider.property.constructor.name,
|
|
1139
|
+
drive: chosen.field,
|
|
1140
|
+
driveResponse: chosen.response,
|
|
1141
|
+
rivals: leaders.filter((p) => p.field !== chosen.field).map((p) => ({ field: p.field, response: p.response })),
|
|
1142
|
+
statedResponse:
|
|
1143
|
+
stated === null || stated === chosen.field ? null : (probes.find((p) => p.field === stated)?.response ?? 0),
|
|
1144
|
+
verdict,
|
|
1145
|
+
};
|
|
1146
|
+
return { slider, reach: reach(discovery), field: chosen.field, u0: chosen.u0, v0: chosen.v0, u1: chosen.u1, v1: chosen.v1 };
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* The clause the frame line carries when the two answers did not simply agree —
|
|
1151
|
+
* empty on every rig where they did, which is every one in the gallery.
|
|
1152
|
+
*
|
|
1153
|
+
* ⭐ It names both answers and both responses, because "the probe and the file
|
|
1154
|
+
* disagree" without the numbers is a sentence an author cannot act on.
|
|
1155
|
+
*/
|
|
1156
|
+
function dialDiscoveryClause(discovery: DialDiscovery, bone: string): string {
|
|
1157
|
+
if (discovery.verdict === 'agreed') return '';
|
|
1158
|
+
const drive = `${bone}.${DIAL_PROPERTY[discovery.drive]} ${discovery.driveResponse.toExponential(3)}`;
|
|
1159
|
+
if (discovery.verdict === 'settled') {
|
|
1160
|
+
const rivals = discovery.rivals
|
|
1161
|
+
.map((r) => `${bone}.${DIAL_PROPERTY[r.field]} ${r.response.toExponential(3)}`)
|
|
1162
|
+
.join(', ');
|
|
1163
|
+
return (
|
|
1164
|
+
`, driven through ${bone}.${DIAL_PROPERTY[discovery.drive]} — the probe did not settle that on its own ` +
|
|
1165
|
+
`(${drive} against ${rivals}, inside the ${DIAL_PROBE_MARGIN}x margin), so the skeleton's own reader broke ` +
|
|
1166
|
+
'the tie'
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
const statedName = discovery.stated === null ? discovery.reader : `${bone}.${DIAL_PROPERTY[discovery.stated]}`;
|
|
1170
|
+
const statedAt =
|
|
1171
|
+
discovery.statedResponse === null ? 'which this file does not know' : `which moves it by ${discovery.statedResponse.toExponential(3)}`;
|
|
1172
|
+
return (
|
|
1173
|
+
`, driven through ${bone}.${DIAL_PROPERTY[discovery.drive]} — the probe moves the reading by ${drive} while ` +
|
|
1174
|
+
`the skeleton says the reader is ${statedName}, ${statedAt}. The two disagree and both are reported: the ` +
|
|
1175
|
+
'drive is the one that measurably moves the reading'
|
|
1176
|
+
);
|
|
910
1177
|
}
|
|
911
1178
|
|
|
912
1179
|
/**
|
|
@@ -996,7 +1263,24 @@ function poseDial(data: SkeletonData, plan: DialPlan, time: number): PoseOfFrame
|
|
|
996
1263
|
};
|
|
997
1264
|
};
|
|
998
1265
|
/** The affine first guess, and what it is judged against. */
|
|
999
|
-
const
|
|
1266
|
+
const asked = plan.u0 + ((value - plan.v0) * (plan.u1 - plan.u0)) / (plan.v1 - plan.v0);
|
|
1267
|
+
// 🚨 The bound, and it is on the drive the answer is READ off, not on the
|
|
1268
|
+
// arithmetic that got there (issue #419). Two different things wanted bounding
|
|
1269
|
+
// and they want it differently:
|
|
1270
|
+
//
|
|
1271
|
+
// - **the mapping's own ask.** A map measured through a field the reading
|
|
1272
|
+
// barely moves inverts to nonsense — this is #419's own case, where a world
|
|
1273
|
+
// scale solved through `rotation` asked for 4.9e9 — and that is the frame
|
|
1274
|
+
// the report would print as advice. Out of bounds, it is not posed at all:
|
|
1275
|
+
// the bone stays at the setup value of its field, the key is unreachable,
|
|
1276
|
+
// and `unreachableWhy` names the ask and the bound.
|
|
1277
|
+
// - **a secant step.** The loop below is a search and a search may step
|
|
1278
|
+
// anywhere; a step out of bounds is a step this stops taking, not a verdict.
|
|
1279
|
+
// `FromRotate`'s wrap sends one there routinely on a rig whose real defect is
|
|
1280
|
+
// the wrap, and reporting the bound instead of the wrap would swap a
|
|
1281
|
+
// diagnosis for a symptom.
|
|
1282
|
+
const runaway = !Number.isFinite(asked) || Math.abs(asked) > DIAL_DRIVE_LIMIT;
|
|
1283
|
+
const first = runaway ? plan.u0 : asked;
|
|
1000
1284
|
const near = 1e-9 * (1 + Math.abs(value));
|
|
1001
1285
|
let u = first;
|
|
1002
1286
|
let got = at(u);
|
|
@@ -1008,11 +1292,11 @@ function poseDial(data: SkeletonData, plan: DialPlan, time: number): PoseOfFrame
|
|
|
1008
1292
|
// on and a wandered secant point is not.
|
|
1009
1293
|
let pu = first === plan.u0 ? plan.u1 : plan.u0;
|
|
1010
1294
|
let pv = Number.NaN;
|
|
1011
|
-
for (let step = 0; step < DIAL_SOLVE_STEPS && Math.abs(got.read - value) > near; step++) {
|
|
1295
|
+
for (let step = 0; !runaway && step < DIAL_SOLVE_STEPS && Math.abs(got.read - value) > near; step++) {
|
|
1012
1296
|
if (!Number.isFinite(pv)) pv = at(pu).read;
|
|
1013
1297
|
if (pv === got.read || !Number.isFinite(pv) || !Number.isFinite(got.read)) break;
|
|
1014
1298
|
const next = u + ((value - got.read) * (u - pu)) / (got.read - pv);
|
|
1015
|
-
if (!Number.isFinite(next)) break;
|
|
1299
|
+
if (!Number.isFinite(next) || Math.abs(next) > DIAL_DRIVE_LIMIT) break;
|
|
1016
1300
|
pu = u;
|
|
1017
1301
|
pv = got.read;
|
|
1018
1302
|
u = next;
|
|
@@ -1027,9 +1311,13 @@ function poseDial(data: SkeletonData, plan: DialPlan, time: number): PoseOfFrame
|
|
|
1027
1311
|
dial: {
|
|
1028
1312
|
value,
|
|
1029
1313
|
driven: u,
|
|
1314
|
+
beyondLimit: runaway ? asked : null,
|
|
1030
1315
|
applied: got.applied,
|
|
1031
1316
|
wanted,
|
|
1032
|
-
unreachable
|
|
1317
|
+
// ⚠️ A drive the bound refused is unreachable WHATEVER the runtime then
|
|
1318
|
+
// landed on. Nothing was posed where the mapping asked, so a time that
|
|
1319
|
+
// happens to match is a coincidence of the setup pose and not a measurement.
|
|
1320
|
+
unreachable: runaway || !(Math.abs(got.applied - wanted) <= DIAL_TIME_EPSILON),
|
|
1033
1321
|
},
|
|
1034
1322
|
};
|
|
1035
1323
|
}
|