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 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` cannot cross 0°.** `local: false`
1410
- reads the bone's **world** rotation through `FromRotate.value`, which ends
1411
- `if (value < 0) value += 360`. A yaw axis authored the natural way neutral at 0°,
1412
- range −15°..+15° therefore never sees a negative value: the bone at −15° arrives
1413
- as 345°, and `time = to + (value from) * scale` lands far past the animation.
1414
- [measured] with `from: 0, to: 0.5, scale: 0.033333` over a 1 s animation the dial
1415
- at −15° applies time **12.000 s**, so with `loop: false` the entire negative half
1416
- of the axis holds the last frame15° of face jumping between −0.001° and 0°, with
1417
- nothing anywhere reporting it. The same rig with `"local": true` ramps
1418
- 0 s 0.5 s 1 s as written.
1419
-
1420
- **`local: true` is the form a face axis wants.** It reads `source.rotation`
1421
- signed and unwrapped, which for a dial bone is the number you authored. rigc
1422
- refuses the crossing case at compile, with that arithmetic in the message; the
1423
- alternative it leaves open is to move the range so it does not cross 0° (a neutral
1424
- at 180°, say), which is the only form `local: false` can express.
1425
-
1426
- ⚠️ **The refusal is on the LOW end only.** `[0, 360)` is the whole of what
1427
- `FromRotate.value` returns, so a range running past **360°** is dead in the same
1428
- way and compiles clean. Nothing refuses that at compile; `A39` reports it from the
1429
- artifact side as a key at a time no dial selects (§4.11.4).
1409
+ 🚨 **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 wayneutral 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 with the dial value its mapping inverts this time to (§4.11.4). On a key at a time no dial selects, an `unreachable` line follows it and the figures below belong to the frame the runtime landed on instead |
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. 🚨 The compiler refuses a range that dips **below**
2719
- (§3.5.2) and says nothing about one running **past 360°** `from: 300` with
2720
- `scale: 0.005` over a 1 s animation needs 300°..500° and compiles clean — so
2721
- this is the surface that sees the second half. `A39` measures the frame the
2722
- runtime *does* land on, leaves the key out of `deformKeysMeasured`, and names
2723
- it:
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° …` | §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 |
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.0",
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
- // so a driving value below 0° is one the runtime never produces: it arrives
3471
- // as `value + 360` instead, and `time = to + (value - from) * scale` then
3472
- // lands far outside the animation. A yaw axis with its neutral at — the
3473
- // natural way to author a face therefore has its entire negative half
3474
- // pinned to one frame, and nothing anywhere says so (issue #402).
3485
+ // `Math.atan2` returns `(-180, 180]` and the `offsets` a slider hands that
3486
+ // reader are `Slider.offsets`, a private all-zero array so **`[0, 360)`
3487
+ // is the whole of what it can ever produce**. A driving value outside that
3488
+ // is one the runtime never returns: it arrives 360° away, and
3489
+ // `time = to + (value - from) * scale` then lands far outside the
3490
+ // animation. A yaw axis with its neutral at 0° — the natural way to author
3491
+ // a face — therefore has its entire negative half pinned to one frame, and
3492
+ // nothing anywhere says so (issue #402).
3475
3493
  //
3476
3494
  // ⭐ Refused here rather than documented, and rather than left to the gate,
3477
3495
  // for three reasons. The failure is invisible AND total, so a note in a
@@ -3483,10 +3501,35 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3483
3501
  // has in front of it: the range of driving values that can reach a frame is
3484
3502
  // exactly `[to, to + duration]` mapped back through `scale`.
3485
3503
  //
3504
+ // ⚠️ **Both ends, out of one piece of arithmetic** (issue #417). As #405
3505
+ // landed it, `highest` was computed and never tested, so a range running
3506
+ // PAST 360° — `"from": 300, "scale": 0.005` over a 1 s animation asks for
3507
+ // 300°..500° — compiled clean with everything above 360° just as dead: turn
3508
+ // the bone to 500° and its world rotation is 140°, read as 140°, mapped to
3509
+ // a time nowhere near the one the author meant. A copy-pasted second branch
3510
+ // with a sign edited is how such a pair drifts apart, so the two ends share
3511
+ // `dead` below and every number in the message comes off it.
3512
+ //
3513
+ // ⭐ **And the line is PAST 360°, not at it** — measured before it was
3514
+ // written, because the two are one degree of arc apart and only one of them
3515
+ // costs an author anything. A range ending exactly on 360° misses exactly
3516
+ // one value, its own supremum, and that value is not a dial position: a
3517
+ // bone at 360° IS a bone at 0°, reads within 5.3e-6° of it and poses the
3518
+ // skeleton identically. Swept through spine-core over 0°..360° at 0.1°, a
3519
+ // `from: 0, scale: 0.0025` dial on a 0.9 s animation lands every reading
3520
+ // within **1.7e-8 s** of the time the mapping asks for, and on `loop: true`
3521
+ // the endpoint is not even distinct — the circle closes on 0.900000 s
3522
+ // exactly. Above 360° the dead set has width instead: 300°..500° reaches
3523
+ // only 0.000 s..0.2995 s of its own 1 s animation, and the dial positions
3524
+ // an author would set for the top 140° of it are read as something else and
3525
+ // land on a wrong frame. That silent wrong answer is the hazard, and it
3526
+ // starts strictly above 360.
3527
+ //
3486
3528
  // The refusal is deliberately narrow: `rotate` only, because `FromRotate` is
3487
3529
  // the one property whose reader wraps, and only when the range actually
3488
- // crosses (or sits below) 0°, because a dial authored at 340°..20° is how
3489
- // you write this axis under `local: false` and it works.
3530
+ // runs outside `[0, 360]`, because a dial inside that circle 20°..340°,
3531
+ // or the whole turn 0°..360° — is how you write this axis under
3532
+ // `local: false` and it works.
3490
3533
  if (property === 'rotate' && spec.local !== true) {
3491
3534
  const fromValue = spec.from === undefined ? 0 : needNumber(spec.from, 'from');
3492
3535
  const toTime = spec.to === undefined ? 0 : needNumber(spec.to, 'to');
@@ -3497,18 +3540,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
- if (lowest < -1e-6) {
3501
- const readAs = lowest + 360;
3502
- const lands = toTime + (readAs - fromValue) * perUnit;
3543
+ // Which end of the circle the range leaves, and which way the reader
3544
+ // moves it: below 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 ${lowest.toFixed(3)}° is read as ${readAs.toFixed(3)}° and maps to time ${lands.toFixed(3)}s — outside ` +
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
- '"loop": true it wraps to some other frame. Either way the whole part of the range below is dead and ' +
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
- 'that is the form a face axis wants — or move the range so it does not cross 0°.',
3579
+ `that is the form a face axis wants — or ${dead.repair}.`,
3512
3580
  );
3513
3581
  }
3514
3582
  }
@@ -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 spine-core's own
302
- * reader class is named minus the `From` (`rotate`, `x`, `y`, `scaleX`, …), or
303
- * `null` when there is no bone. Derived from which local field moves the
304
- * slider's time rather than from a table, so it cannot drift from the runtime.
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 a `rotate` slider reading a WORLD rotation
355
- * cannot be driven below and everything the inversion asks for down there
356
- * arrives 360° away (issue #405). `Math.max(0, time)` is the other.
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. The compiler refuses a range that dips
586
- // below 0° — the natural way to author a face yaw, and the case #405 was filed
587
- // on and says nothing about one that runs past 360°, which dies in exactly
588
- // the same way. This is the surface that sees it.
636
+ // ⚠️ Both ends, not just the low one a range running past 360° dies in
637
+ // exactly the way one dipping below 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.** Which `BonePose` field a `FromProperty`
871
- * reads is spine-core's business, and under `local: false` the reader goes
872
- * through the world transform — so a table here would be a second copy of the
873
- * runtime's dispatch AND a claim about parents this file has no business making.
874
- * Two calls to `data.property.value` — the same call `Slider.update` makes, with
875
- * the same all-zero offsets — say it instead, and every one of the six readers is
876
- * affine in its own field at a fixed parent pose, so two points are the whole map.
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 reach = (field: DialField | null): DeformReach => ({
880
- kind: 'slider',
881
- slider: slider.name,
882
- bone: slider.bone?.name ?? null,
883
- property: field === null ? null : DIAL_PROPERTY[field],
884
- local: slider.local,
885
- label:
886
- field === null
887
- ? `applied by slider "${slider.name}" at its own time`
888
- : `applied by slider "${slider.name}" off ${slider.bone?.name ?? '?'}.${DIAL_PROPERTY[field]}` +
889
- `${slider.local ? ' (local)' : ' (world)'}`,
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
- if (v1 === v0) continue;
905
- return { slider, reach: reach(field), field, u0: base, v0, u1: base + step, v1 };
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 first = plan.u0 + ((value - plan.v0) * (plan.u1 - plan.u0)) / (plan.v1 - plan.v0);
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: !(Math.abs(got.applied - wanted) <= DIAL_TIME_EPSILON),
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
  }