spine-rigc 0.18.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.ts +4 -1
- package/docs/AUTHORING.md +254 -30
- package/package.json +1 -1
- package/src/compile.ts +215 -14
- package/src/deformmeasure.ts +583 -38
- package/src/validate.ts +81 -1
package/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,24 +305,127 @@ 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. */
|
|
310
331
|
label: string;
|
|
311
332
|
}
|
|
312
333
|
|
|
334
|
+
/** The span of one animation's own `0..duration` a dial can select, in seconds. */
|
|
335
|
+
export interface DialSpan {
|
|
336
|
+
lo: number;
|
|
337
|
+
hi: number;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* A dial whose probe **tied** and whose artifact broke the tie (issue #419).
|
|
342
|
+
*
|
|
343
|
+
* 🔒 **Not a disagreement, and it must never be reported as one.** There is one
|
|
344
|
+
* belief here, not two: the probe measured two fields moving the reading by the
|
|
345
|
+
* same amount, named neither, and the skeleton's own reader said which of them
|
|
346
|
+
* the author wrote. A `FromX` slider under `local: false` on a bone whose parent
|
|
347
|
+
* is at 45° is exactly that, and it is legitimate geometry. So there is no second
|
|
348
|
+
* answer, no second reach and nothing to compare — which is why this is a type of
|
|
349
|
+
* its own and not a `verdict` field on the one below.
|
|
350
|
+
*/
|
|
351
|
+
export interface DeformDialTie {
|
|
352
|
+
/** The slider whose dial this is. */
|
|
353
|
+
slider: string;
|
|
354
|
+
/** Its driving bone. */
|
|
355
|
+
bone: string;
|
|
356
|
+
/** The field the artifact named, which is therefore the one the survey drives. */
|
|
357
|
+
drive: string;
|
|
358
|
+
/** What one step of it moved the reading by. */
|
|
359
|
+
driveResponse: number;
|
|
360
|
+
/** The other fields inside `DIAL_PROBE_MARGIN` of it, and what each moved it by. */
|
|
361
|
+
rivals: Array<{ field: string; response: number }>;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* A dial the artifact and the probe name **differently**, with the span each of
|
|
366
|
+
* their answers can select (issues #419, #427).
|
|
367
|
+
*
|
|
368
|
+
* ⭐ Structured, and on the survey rather than only inside `DeformReach.label`,
|
|
369
|
+
* because that label is prose only `explain` prints. A `build`-only run is the
|
|
370
|
+
* loop an agent that cannot see the rig actually runs, and until this existed the
|
|
371
|
+
* fact that rigc's two halves disagreed about which dial was turned reached that
|
|
372
|
+
* run not at all.
|
|
373
|
+
*
|
|
374
|
+
* 🚨 **What the two reaches settle.** The property's name is not what makes this
|
|
375
|
+
* survey trustworthy — the set of frames it poses is. So both answers are turned
|
|
376
|
+
* into the span of the animation each can select, and `outside` is the part of
|
|
377
|
+
* what was posed that the artifact's answer could not have reached. Measured
|
|
378
|
+
* (issue #427): under a disagreement `driveReach` **always contains**
|
|
379
|
+
* `statedReach`, because the driven field is the largest response the probe found
|
|
380
|
+
* and a disagreement needs it to clear the artifact's field by
|
|
381
|
+
* `DIAL_PROBE_MARGIN`. So the survey never poses fewer frames than the artifact's
|
|
382
|
+
* answer would have, and `outside` is what the artifact's answer would have
|
|
383
|
+
* MISSED — never what this one invented.
|
|
384
|
+
*/
|
|
385
|
+
export interface DeformDialDispute {
|
|
386
|
+
/** The slider whose dial this is. */
|
|
387
|
+
slider: string;
|
|
388
|
+
/** Its driving bone. */
|
|
389
|
+
bone: string;
|
|
390
|
+
/**
|
|
391
|
+
* The field the artifact names, rig-spec spelled — or the reader's class name
|
|
392
|
+
* when it is one this file does not know.
|
|
393
|
+
*/
|
|
394
|
+
stated: string;
|
|
395
|
+
/** What one step of the artifact's field moves the reading by. */
|
|
396
|
+
statedResponse: number | null;
|
|
397
|
+
/**
|
|
398
|
+
* The part of the animation's own `0..duration` the artifact's field can
|
|
399
|
+
* select, or `null` when it can select none of it — and on the one reader
|
|
400
|
+
* this file cannot name, where there is no field to probe.
|
|
401
|
+
*/
|
|
402
|
+
statedReach: DialSpan | null;
|
|
403
|
+
/** The field the survey drives, which is the one that measurably moves the reading. */
|
|
404
|
+
drive: string;
|
|
405
|
+
/** What one step of THAT moves the reading by. */
|
|
406
|
+
driveResponse: number;
|
|
407
|
+
/** The part of `0..duration` the drive can select, or `null` when it can select none. */
|
|
408
|
+
driveReach: DialSpan | null;
|
|
409
|
+
/**
|
|
410
|
+
* The deform key times this survey posed through `drive` that no settable value
|
|
411
|
+
* of the artifact's field reaches, in ascending order.
|
|
412
|
+
*
|
|
413
|
+
* ⭐ Empty is a **reading**, not an absence: it says both answers pose the same
|
|
414
|
+
* frames, so the disagreement changed nothing about what was measured. A39
|
|
415
|
+
* prints `outside:none` for it rather than omitting the field, because a
|
|
416
|
+
* comparison that was made and came out equal must not look like one nobody
|
|
417
|
+
* made.
|
|
418
|
+
*/
|
|
419
|
+
outside: number[];
|
|
420
|
+
}
|
|
421
|
+
|
|
313
422
|
/** The reach every animation has when no slider applies it. */
|
|
314
423
|
const TRACK_REACH: DeformReach = {
|
|
315
424
|
kind: 'track',
|
|
316
425
|
slider: null,
|
|
317
426
|
bone: null,
|
|
318
427
|
property: null,
|
|
428
|
+
drive: null,
|
|
319
429
|
local: false,
|
|
320
430
|
label: 'played on a track',
|
|
321
431
|
};
|
|
@@ -343,6 +453,17 @@ export interface DeformDial {
|
|
|
343
453
|
* `local: false`, where the reader goes through the world transform.
|
|
344
454
|
*/
|
|
345
455
|
driven: number;
|
|
456
|
+
/**
|
|
457
|
+
* The drive the mapping asked for when it was past `DIAL_DRIVE_LIMIT` and the
|
|
458
|
+
* bone was therefore stopped at the bound instead — `null` on every dial that
|
|
459
|
+
* stayed inside it, which is every correct rig (issue #419).
|
|
460
|
+
*
|
|
461
|
+
* 🚨 The runaway made visible. Before #419 a world scale slider was solved
|
|
462
|
+
* through `rotation`, whose response to it is float noise, and the drive ran to
|
|
463
|
+
* 4.9e9 with nothing saying so. Now the ask is carried here, the bone is not
|
|
464
|
+
* posed there, and the key is reported unreachable naming both numbers.
|
|
465
|
+
*/
|
|
466
|
+
beyondLimit: number | null;
|
|
346
467
|
/** `SliderPose.time` the runtime then computed, read off the posed skeleton. */
|
|
347
468
|
applied: number;
|
|
348
469
|
/** What `Slider.update` would have stored for this key's own time. */
|
|
@@ -351,9 +472,11 @@ export interface DeformDial {
|
|
|
351
472
|
* `applied` is not `wanted`: **no dial value selects this key's time.**
|
|
352
473
|
*
|
|
353
474
|
* The reachable one, and it is not hypothetical: `FromRotate.value` ends
|
|
354
|
-
* `if (value < 0) value += 360`, so
|
|
355
|
-
*
|
|
356
|
-
* arrives 360° away
|
|
475
|
+
* `if (value < 0) value += 360` over an `atan2`, so `[0, 360)` is the whole of
|
|
476
|
+
* what a `rotate` slider reading a WORLD rotation can be driven to, and
|
|
477
|
+
* everything the inversion asks for outside it arrives 360° away — below 0°
|
|
478
|
+
* (issue #405) and at or above 360° (issue #417) alike. `Math.max(0, time)` is
|
|
479
|
+
* the other.
|
|
357
480
|
*
|
|
358
481
|
* ⚠️ A key like that is measured — at the frame the runtime does land on, which
|
|
359
482
|
* the report names — and then left OUT of the gate's counts, because the
|
|
@@ -559,6 +682,17 @@ export interface DeformSurvey {
|
|
|
559
682
|
* and this is (`DW16`).
|
|
560
683
|
*/
|
|
561
684
|
spanProbes: number;
|
|
685
|
+
/**
|
|
686
|
+
* Dials whose probe tied and whose artifact broke the tie, in the skeleton's
|
|
687
|
+
* own constraint order (issue #419). Empty on every rig where it did not.
|
|
688
|
+
*/
|
|
689
|
+
dialTies: DeformDialTie[];
|
|
690
|
+
/**
|
|
691
|
+
* Dials the artifact and the probe name differently, in the same order, each
|
|
692
|
+
* carrying both answers, both reaches and the frames the artifact's answer
|
|
693
|
+
* could not have posed (issues #419, #427).
|
|
694
|
+
*/
|
|
695
|
+
dialDisputes: DeformDialDispute[];
|
|
562
696
|
}
|
|
563
697
|
|
|
564
698
|
/**
|
|
@@ -577,15 +711,33 @@ export function unreachableWhy(key: DeformKeyMeasure): string {
|
|
|
577
711
|
key.reach.bone === null
|
|
578
712
|
? `slider "${key.reach.slider}"'s own time`
|
|
579
713
|
: `${key.reach.bone}.${key.reach.property} (${key.reach.local ? 'local' : 'world'})`;
|
|
714
|
+
// 🚨 The runaway, named with both numbers instead of printed as a dial (issue
|
|
715
|
+
// #419). This comes first because it is a different fact from the one below: the
|
|
716
|
+
// bone was never put where the mapping asked, so what the runtime applied is the
|
|
717
|
+
// clamp's time and says nothing about whether the key is otherwise reachable.
|
|
718
|
+
if (dial.beyondLimit !== null) {
|
|
719
|
+
const field = key.reach.drive ?? key.reach.property;
|
|
720
|
+
return (
|
|
721
|
+
`the dial for t=${key.time}s is out of bounds: slider "${key.reach.slider}" maps that time back to ` +
|
|
722
|
+
`${dial.value.toFixed(6)}, which needs ${key.reach.bone}.${field} at ${dial.beyondLimit.toExponential(4)} — ` +
|
|
723
|
+
`past the ±${DIAL_DRIVE_LIMIT} this solve will drive a bone field to. Past 2^24 a float32 no longer holds ` +
|
|
724
|
+
'two consecutive integers apart, so a figure up there is not a value anybody can set and read back. The ' +
|
|
725
|
+
'bone was left at its setup value rather than driven there, so what was posed is the setup frame and not ' +
|
|
726
|
+
`the one that mapping names — the runtime applied ${dial.applied.toFixed(6)}s against the ` +
|
|
727
|
+
`${dial.wanted.toFixed(6)}s wanted`
|
|
728
|
+
);
|
|
729
|
+
}
|
|
580
730
|
// The one reader that cannot produce a value it is asked for, named where an
|
|
581
731
|
// author will meet it: a WORLD rotation is an `atan2` ending in
|
|
582
732
|
// `if (value < 0) value += 360`, so **[0, 360) is the whole of its range** and
|
|
583
733
|
// `"local": true` is the fix (issue #405).
|
|
584
734
|
//
|
|
585
|
-
// ⚠️ Both ends, not just the low one
|
|
586
|
-
//
|
|
587
|
-
//
|
|
588
|
-
//
|
|
735
|
+
// ⚠️ Both ends, not just the low one — a range running past 360° dies in
|
|
736
|
+
// exactly the way one dipping below 0° does. The compiler refuses both since
|
|
737
|
+
// issue #417 (it refused only the low end when #405 landed), so a rig spec in
|
|
738
|
+
// this repository cannot ask for either. This clause is still not redundant:
|
|
739
|
+
// an artifact reaching the gate from the editor, a hand edit or an older rigc
|
|
740
|
+
// can carry a dead range, and the artifact is what this file reads.
|
|
589
741
|
const wrap =
|
|
590
742
|
key.reach.property === 'rotate' && !key.reach.local && (dial.value < 0 || dial.value >= 360)
|
|
591
743
|
? '. A world rotation is read through `FromRotate.value`, an `atan2` ending `if (value < 0) value += 360`, ' +
|
|
@@ -640,6 +792,8 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
640
792
|
let notReachable = 0;
|
|
641
793
|
let notReachableReversed = 0;
|
|
642
794
|
let spansNotScanned = 0;
|
|
795
|
+
const dialTies: DeformDialTie[] = [];
|
|
796
|
+
const dialDisputes: DeformDialDispute[] = [];
|
|
643
797
|
const reaches = reachesOf(data);
|
|
644
798
|
for (const anim of data.animations) {
|
|
645
799
|
// One pass per way in (issue #407). The animations nothing applies get the
|
|
@@ -648,6 +802,17 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
648
802
|
const poseFrame = (time: number): PoseOfFrame =>
|
|
649
803
|
dials === null ? { posed: poseAt(data, anim.name, time), dial: null } : poseDial(data, dials, time);
|
|
650
804
|
const reach = dials === null ? TRACK_REACH : dials.reach;
|
|
805
|
+
/**
|
|
806
|
+
* The key times this plan actually **reached**, for the reach comparison
|
|
807
|
+
* below (#427).
|
|
808
|
+
*
|
|
809
|
+
* ⚠️ A key the drive itself could not select is not in here. It is already
|
|
810
|
+
* named on the stats line as `deformKeysUnreachable`, with the ask and the
|
|
811
|
+
* bound, and the artifact's answer cannot reach it either — so listing it
|
|
812
|
+
* as something the artifact's answer missed would count one defect twice
|
|
813
|
+
* and inflate a disagreement with a frame the disagreement did not cost.
|
|
814
|
+
*/
|
|
815
|
+
const posedTimes = new Set<number>();
|
|
651
816
|
for (const timeline of anim.timelines) {
|
|
652
817
|
if (!(timeline instanceof DeformTimeline)) continue;
|
|
653
818
|
timelines++;
|
|
@@ -695,6 +860,7 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
695
860
|
notDrawnReversed += frameMeasure.measure.reversed.length;
|
|
696
861
|
}
|
|
697
862
|
keys.push({ ...named, key: frame, time, ...frameMeasure.measure });
|
|
863
|
+
if (at.dial?.unreachable !== true) posedTimes.add(time);
|
|
698
864
|
if (previous !== null) {
|
|
699
865
|
// ⚠️ A span whose end is a frame the runtime cannot reach has no
|
|
700
866
|
// interpolation to scan: the anchors it would solve the quadratic
|
|
@@ -720,6 +886,56 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
720
886
|
previous = frameMeasure;
|
|
721
887
|
}
|
|
722
888
|
}
|
|
889
|
+
// --- what the artifact's own answer could NOT have posed (issue #427) ---
|
|
890
|
+
//
|
|
891
|
+
// ⭐ Posed, not predicted. The survey builds a second plan out of the field
|
|
892
|
+
// the SKELETON names and runs it through the same `poseDial` every real
|
|
893
|
+
// frame goes through, so each entry is spine-core saying "no settable value
|
|
894
|
+
// of this field lands me on that time" rather than this file inferring it
|
|
895
|
+
// off an interval. Measured (#427): the list is empty whenever the two
|
|
896
|
+
// answers reach the same span, and empty is the reading that says the
|
|
897
|
+
// disagreement changed nothing about which frames were measured.
|
|
898
|
+
//
|
|
899
|
+
// 🚨 And nothing is reported at all about a dial that posed NOTHING — a
|
|
900
|
+
// slider whose animation carries no deform timeline. `outside` would be
|
|
901
|
+
// empty there for the one reason that must never print as agreement:
|
|
902
|
+
// there were no frames to disagree about. A comparison of nothing and a
|
|
903
|
+
// comparison that came out equal are the vacuous pass this file exists
|
|
904
|
+
// to keep apart.
|
|
905
|
+
if (posedTimes.size === 0) continue;
|
|
906
|
+
// A plan is visited exactly once — a slider names one animation — so the
|
|
907
|
+
// two lists need no de-duplication and come out in the skeleton's own
|
|
908
|
+
// constraint order.
|
|
909
|
+
if (dials?.tie) dialTies.push(dials.tie);
|
|
910
|
+
if (dials?.dispute) dialDisputes.push(dials.dispute);
|
|
911
|
+
if (dials?.dispute && dials.statedMap !== null) {
|
|
912
|
+
const shadow: DialPlan = {
|
|
913
|
+
...dials,
|
|
914
|
+
field: dials.statedMap.field,
|
|
915
|
+
u0: dials.statedMap.u0,
|
|
916
|
+
v0: dials.statedMap.v0,
|
|
917
|
+
u1: dials.statedMap.u1,
|
|
918
|
+
v1: dials.statedMap.v1,
|
|
919
|
+
};
|
|
920
|
+
// ⚠️ The one time posing cannot answer: a field that moves the reading by
|
|
921
|
+
// NOTHING has no map to invert, so `poseDial` divides by zero and calls
|
|
922
|
+
// every time out of bounds — including the setup time, which that field
|
|
923
|
+
// reaches by being left alone. The reach says which one that is, and it
|
|
924
|
+
// is a single point. Nothing in spine-core 4.3 has been measured getting
|
|
925
|
+
// here (a parent at exactly 90° still moves a world x reading by 2.3e-8),
|
|
926
|
+
// and a report that over-stated a disagreement by one key would be the
|
|
927
|
+
// false red this file has paid for twice.
|
|
928
|
+
const flat = dials.statedMap.v1 === dials.statedMap.v0;
|
|
929
|
+
const only = dials.dispute.statedReach;
|
|
930
|
+
const outside = [...posedTimes]
|
|
931
|
+
.filter((time) =>
|
|
932
|
+
flat
|
|
933
|
+
? only === null || Math.abs(time - only.lo) > DIAL_TIME_EPSILON
|
|
934
|
+
: poseDial(data, shadow, time).dial?.unreachable === true,
|
|
935
|
+
)
|
|
936
|
+
.sort((a, b) => a - b);
|
|
937
|
+
dials.dispute.outside.push(...outside);
|
|
938
|
+
}
|
|
723
939
|
}
|
|
724
940
|
}
|
|
725
941
|
let spanProbes = 0;
|
|
@@ -749,6 +965,8 @@ export function surveyDeformKeys(data: SkeletonData, exempt: ReadonlySet<string>
|
|
|
749
965
|
spansUnconfirmed,
|
|
750
966
|
spansNotScanned,
|
|
751
967
|
spanProbes,
|
|
968
|
+
dialTies,
|
|
969
|
+
dialDisputes,
|
|
752
970
|
};
|
|
753
971
|
}
|
|
754
972
|
|
|
@@ -789,6 +1007,70 @@ function dialStep(field: DialField): number {
|
|
|
789
1007
|
return field === 'scaleX' || field === 'scaleY' ? 0.25 : 1;
|
|
790
1008
|
}
|
|
791
1009
|
|
|
1010
|
+
/**
|
|
1011
|
+
* Which `BonePose` field spine-core's own reader is named for — off the ARTIFACT,
|
|
1012
|
+
* which is the second, independent answer the probe below is checked against
|
|
1013
|
+
* (issue #419).
|
|
1014
|
+
*
|
|
1015
|
+
* ⭐ This is an identity, not a dispatch table. `SliderData.property` is one of
|
|
1016
|
+
* six classes the parser built out of the rig spec's `property` field, so asking
|
|
1017
|
+
* which class it is asks the file what the author wrote; it says nothing about
|
|
1018
|
+
* *how* the value is computed, which is the part #407 was careful never to
|
|
1019
|
+
* transcribe and which the probe still measures.
|
|
1020
|
+
*
|
|
1021
|
+
* `null` for a reader this file does not know — unreachable against spine-core
|
|
1022
|
+
* 4.3, which has exactly these six, and deliberately not folded into a default:
|
|
1023
|
+
* a seventh reader in some later runtime must be **named** in the report, not
|
|
1024
|
+
* silently mapped to whatever the probe happened to find.
|
|
1025
|
+
*/
|
|
1026
|
+
function readerField(property: FromProperty): DialField | null {
|
|
1027
|
+
if (property instanceof FromRotate) return 'rotation';
|
|
1028
|
+
if (property instanceof FromX) return 'x';
|
|
1029
|
+
if (property instanceof FromY) return 'y';
|
|
1030
|
+
if (property instanceof FromScaleX) return 'scaleX';
|
|
1031
|
+
if (property instanceof FromScaleY) return 'scaleY';
|
|
1032
|
+
if (property instanceof FromShearY) return 'shearY';
|
|
1033
|
+
return null;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
/**
|
|
1037
|
+
* How the artifact's answer and the probe's answer settled (issue #419).
|
|
1038
|
+
*
|
|
1039
|
+
* - `agreed` — one field responds decisively and it is the reader's own. Every
|
|
1040
|
+
* rig in the gallery and every `local: true` slider.
|
|
1041
|
+
* - `settled` — the probe was **not** decisive (two or more fields within
|
|
1042
|
+
* `DIAL_PROBE_MARGIN` of each other) and the reader's own field is one of them,
|
|
1043
|
+
* so the artifact broke the tie. Real: a `FromX` slider under `local: false` on
|
|
1044
|
+
* a bone whose parent is at 45° is driven exactly equally by local `x` and
|
|
1045
|
+
* local `y`, and letting list order decide between them would be the same
|
|
1046
|
+
* silent arbitrariness #419 was filed on.
|
|
1047
|
+
* - `disagreed` — the field that moves the reading is not the reader's own field.
|
|
1048
|
+
* Also real: the same slider with the parent at 90° is moved by local `y` alone
|
|
1049
|
+
* and not at all by local `x`. The probe wins the drive, because it is the one
|
|
1050
|
+
* that measured something, and the disagreement is printed rather than resolved.
|
|
1051
|
+
*/
|
|
1052
|
+
type DialVerdict = 'agreed' | 'settled' | 'disagreed';
|
|
1053
|
+
|
|
1054
|
+
/** The two answers, what they were, and how they settled. */
|
|
1055
|
+
interface DialDiscovery {
|
|
1056
|
+
/** The reader's own field, off the artifact. `null` on a reader not known here. */
|
|
1057
|
+
stated: DialField | null;
|
|
1058
|
+
/** What the artifact's reader is called, for a report that has to name it. */
|
|
1059
|
+
reader: string;
|
|
1060
|
+
/** The field the solve drives. Always one the probe measured a response on. */
|
|
1061
|
+
drive: DialField;
|
|
1062
|
+
/** What one step of that field moved the reading by. */
|
|
1063
|
+
driveResponse: number;
|
|
1064
|
+
/**
|
|
1065
|
+
* The other fields inside `DIAL_PROBE_MARGIN` of the winner — the ones that
|
|
1066
|
+
* stopped the probe being decisive. Empty whenever it was.
|
|
1067
|
+
*/
|
|
1068
|
+
rivals: Array<{ field: DialField; response: number }>;
|
|
1069
|
+
/** What the reader's OWN field responded, when it is not the one driven. */
|
|
1070
|
+
statedResponse: number | null;
|
|
1071
|
+
verdict: DialVerdict;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
792
1074
|
/**
|
|
793
1075
|
* A slider whose animation is being posed, with everything the inversion needs
|
|
794
1076
|
* measured off the runtime rather than assumed.
|
|
@@ -796,6 +1078,13 @@ function dialStep(field: DialField): number {
|
|
|
796
1078
|
interface DialPlan {
|
|
797
1079
|
slider: SliderData;
|
|
798
1080
|
reach: DeformReach;
|
|
1081
|
+
/**
|
|
1082
|
+
* The probe's tie, when it had one — the survey collects these so a `build`
|
|
1083
|
+
* sees them and not only `explain` (issues #419, #427).
|
|
1084
|
+
*/
|
|
1085
|
+
tie: DeformDialTie | null;
|
|
1086
|
+
/** The two answers and their two reaches, when they named different fields. */
|
|
1087
|
+
dispute: DeformDialDispute | null;
|
|
799
1088
|
/**
|
|
800
1089
|
* The bone field that drives it, or `null` on a bone-less slider — whose time
|
|
801
1090
|
* IS its pose value and is set directly.
|
|
@@ -806,6 +1095,30 @@ interface DialPlan {
|
|
|
806
1095
|
v0: number;
|
|
807
1096
|
u1: number;
|
|
808
1097
|
v1: number;
|
|
1098
|
+
/**
|
|
1099
|
+
* The ARTIFACT's own field as a map of its own, kept only on a disputed dial
|
|
1100
|
+
* (issue #427).
|
|
1101
|
+
*
|
|
1102
|
+
* ⭐ It is what lets `DeformDialDispute.outside` be **posed** rather than
|
|
1103
|
+
* predicted: the survey builds a second plan out of it and runs the same
|
|
1104
|
+
* `poseDial` the real frames go through, so "no settable value of `knob.x`
|
|
1105
|
+
* reaches t=0.5s" is a measurement taken against spine-core and not an
|
|
1106
|
+
* inference off an interval.
|
|
1107
|
+
*/
|
|
1108
|
+
statedMap: DialProbe | null;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/**
|
|
1112
|
+
* One field's probe: how far one step of it moved the reading, and the two points
|
|
1113
|
+
* of the affine map that step traced.
|
|
1114
|
+
*/
|
|
1115
|
+
interface DialProbe {
|
|
1116
|
+
field: DialField;
|
|
1117
|
+
response: number;
|
|
1118
|
+
u0: number;
|
|
1119
|
+
v0: number;
|
|
1120
|
+
u1: number;
|
|
1121
|
+
v1: number;
|
|
809
1122
|
}
|
|
810
1123
|
|
|
811
1124
|
/**
|
|
@@ -823,6 +1136,51 @@ const DIAL_TIME_EPSILON = 1e-6;
|
|
|
823
1136
|
/** How many secant steps the solve takes before it calls a time unreachable. */
|
|
824
1137
|
const DIAL_SOLVE_STEPS = 4;
|
|
825
1138
|
|
|
1139
|
+
/**
|
|
1140
|
+
* How far the largest probe response has to clear the runner-up before the probe
|
|
1141
|
+
* alone is allowed to name the field the solve drives (issue #419).
|
|
1142
|
+
*
|
|
1143
|
+
* ⭐ **A ratio, so it carries no units and no fixture's scale.** What it separates
|
|
1144
|
+
* was measured rather than assumed, on this file's own probe:
|
|
1145
|
+
*
|
|
1146
|
+
* | reading | field | response to one step | ratio to the winner |
|
|
1147
|
+
* | --- | --- | --- | --- |
|
|
1148
|
+
* | `FromScaleX`, world, plain parent | `scaleX` | 2.5e-1 | — |
|
|
1149
|
+
* | | `rotation` | 4.0e-10 (float noise in `sqrt(a²+c²)`) | 6.2e8 |
|
|
1150
|
+
* | `FromX`, world, parent at 90° | `y` | 1.0 | — |
|
|
1151
|
+
* | | `x` | 4.6e-8 (float noise) | 2.2e7 |
|
|
1152
|
+
* | `FromScaleX`, world, parent scaled 2×1 | `scaleX` | 5.0e-1 | — |
|
|
1153
|
+
* | | `rotation` | 2.3e-4 (**real**, not noise) | 2.2e3 |
|
|
1154
|
+
* | `FromX`, world, parent at 45° | `x` | 7.07e-1 | — |
|
|
1155
|
+
* | | `y` | 7.07e-1 (**real**, and exactly equal) | 1.0 |
|
|
1156
|
+
*
|
|
1157
|
+
* So 1e3 sits between the smallest genuine secondary dependency measured (2.2e3)
|
|
1158
|
+
* and the largest float-noise ratio (2.2e7), with four orders of headroom on the
|
|
1159
|
+
* side that matters. ⚠️ It is not a noise threshold and must not be read as one:
|
|
1160
|
+
* a rig with a heavier parent scale pushes that 2.2e3 down through it, and what
|
|
1161
|
+
* happens then is not a wrong answer but an *ambiguous* one — which this file
|
|
1162
|
+
* reports and the artifact settles, below.
|
|
1163
|
+
*/
|
|
1164
|
+
const DIAL_PROBE_MARGIN = 1e3;
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* The largest magnitude the solve will drive a bone field to (issue #419).
|
|
1168
|
+
*
|
|
1169
|
+
* 🚨 **This is the bound that replaces "gave up at 4.9e9".** A dial figure is an
|
|
1170
|
+
* instruction — *set `knob.scaleX` to this* — and past 2²⁴ a float32 cannot hold
|
|
1171
|
+
* two consecutive integers apart, so a number up there is not one an author can
|
|
1172
|
+
* set and read back; the emitted skeleton, which rounds to six decimals, has lost
|
|
1173
|
+
* the precision long before. A drive the mapping asks for beyond this is
|
|
1174
|
+
* therefore **not posed**: the bone stops at the bound, the runtime lands on some
|
|
1175
|
+
* other time, and the key is reported unreachable with the ask and the bound both
|
|
1176
|
+
* named — never printed as if it were advice.
|
|
1177
|
+
*
|
|
1178
|
+
* The runaway #419 was filed on reached 1.2e9, 2.5e9 and 4.9e9, all two orders
|
|
1179
|
+
* above this, and every dial `gallery/look` and the DW fixtures ask for is under
|
|
1180
|
+
* 200.
|
|
1181
|
+
*/
|
|
1182
|
+
const DIAL_DRIVE_LIMIT = 2 ** 24;
|
|
1183
|
+
|
|
826
1184
|
/**
|
|
827
1185
|
* Every way each animation is reached, keyed by animation name.
|
|
828
1186
|
*
|
|
@@ -867,46 +1225,212 @@ function sliderOn(skeleton: Skeleton, data: SliderData): Slider | null {
|
|
|
867
1225
|
* Find which local field of the driving bone moves a slider's time, and read the
|
|
868
1226
|
* affine map from that field to the property value off two probes.
|
|
869
1227
|
*
|
|
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
|
|
1228
|
+
* ⭐ **Probed rather than tabulated.** How a `FromProperty` turns a bone into a
|
|
1229
|
+
* number is spine-core's business, and under `local: false` it goes through the
|
|
1230
|
+
* world transform — so a table of that here would be a second copy of the
|
|
1231
|
+
* runtime's arithmetic AND a claim about parents this file has no business
|
|
1232
|
+
* making. Calls to `data.property.value` — the same call `Slider.update` makes,
|
|
1233
|
+
* with the same all-zero offsets — say it instead, and every one of the six
|
|
1234
|
+
* readers is affine in its own field at a fixed parent pose, so two points of the
|
|
1235
|
+
* winner are the whole map.
|
|
1236
|
+
*
|
|
1237
|
+
* 🚨 **What the probe is NOT allowed to do is stop at the first field that
|
|
1238
|
+
* moves** (issue #419). A world scale is read as `sqrt(a² + c²)`; perturbing
|
|
1239
|
+
* `rotation` changes `a` and `c` by amounts that do not cancel in floating point,
|
|
1240
|
+
* `rotation` is probed first, and every world `scaleX` / `scaleY` / `shearY`
|
|
1241
|
+
* slider was therefore reported as `rotate` — and then solved through a field
|
|
1242
|
+
* whose response to it is 4e-10, which is how a drive reached 4.9e9. So:
|
|
1243
|
+
*
|
|
1244
|
+
* 1. **every** field is probed and they are ranked by response;
|
|
1245
|
+
* 2. the winner has to clear the runner-up by `DIAL_PROBE_MARGIN` to be called
|
|
1246
|
+
* decisive — a tie is a real thing (a `FromX` slider on a bone whose parent
|
|
1247
|
+
* is at 45° is moved exactly equally by local `x` and local `y`) and letting
|
|
1248
|
+
* `DIAL_FIELDS`'s order settle it silently is the same defect one step over;
|
|
1249
|
+
* 3. and the answer is cross-checked against the **artifact** — spine-core's own
|
|
1250
|
+
* reader class, which is what the parser built out of the rig spec's
|
|
1251
|
+
* `property`. That is what the report names, it settles a tie the probe could
|
|
1252
|
+
* not, and where the two genuinely differ the report says so by name.
|
|
1253
|
+
*
|
|
1254
|
+
* ⛔ Nothing here falls back to `rotation`, or to any field, when the search does
|
|
1255
|
+
* not settle. Two answers that disagree are two answers, printed.
|
|
877
1256
|
*/
|
|
878
1257
|
function planDial(data: SkeletonData, slider: SliderData): DialPlan | null {
|
|
879
|
-
const
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
:
|
|
889
|
-
|
|
890
|
-
|
|
1258
|
+
const boneName = slider.bone?.name ?? '?';
|
|
1259
|
+
const where = slider.local ? ' (local)' : ' (world)';
|
|
1260
|
+
const reach = (discovery: DialDiscovery | null): DeformReach => {
|
|
1261
|
+
if (discovery === null) {
|
|
1262
|
+
return {
|
|
1263
|
+
kind: 'slider',
|
|
1264
|
+
slider: slider.name,
|
|
1265
|
+
bone: slider.bone?.name ?? null,
|
|
1266
|
+
property: null,
|
|
1267
|
+
drive: null,
|
|
1268
|
+
local: slider.local,
|
|
1269
|
+
label: `applied by slider "${slider.name}" at its own time`,
|
|
1270
|
+
};
|
|
1271
|
+
}
|
|
1272
|
+
// The name comes off the artifact whenever the artifact has one. A reader
|
|
1273
|
+
// this file does not know is named by its class rather than by a guess.
|
|
1274
|
+
const property = discovery.stated === null ? discovery.reader : DIAL_PROPERTY[discovery.stated];
|
|
1275
|
+
const drive = discovery.stated === discovery.drive ? null : DIAL_PROPERTY[discovery.drive];
|
|
1276
|
+
return {
|
|
1277
|
+
kind: 'slider',
|
|
1278
|
+
slider: slider.name,
|
|
1279
|
+
bone: slider.bone?.name ?? null,
|
|
1280
|
+
property,
|
|
1281
|
+
drive,
|
|
1282
|
+
local: slider.local,
|
|
1283
|
+
label:
|
|
1284
|
+
`applied by slider "${slider.name}" off ${boneName}.${property}${where}` +
|
|
1285
|
+
dialDiscoveryClause(discovery, boneName),
|
|
1286
|
+
};
|
|
1287
|
+
};
|
|
891
1288
|
// The bone-less form: `Slider.update` leaves `p.time` alone, so the dial IS the
|
|
892
1289
|
// pose value and the map is the identity.
|
|
893
|
-
if (slider.bone === null)
|
|
1290
|
+
if (slider.bone === null) {
|
|
1291
|
+
return { slider, reach: reach(null), tie: null, dispute: null, field: null, u0: 0, v0: 0, u1: 1, v1: 1, statedMap: null };
|
|
1292
|
+
}
|
|
894
1293
|
const skeleton = new Skeleton(data);
|
|
895
1294
|
const instance = sliderOn(skeleton, slider);
|
|
896
1295
|
const bone = instance?.bone ?? null;
|
|
897
1296
|
if (instance === null || bone === null) return null;
|
|
1297
|
+
// ⚠️ Every field is kept, the dead ones included, because the ARTIFACT may name
|
|
1298
|
+
// one of them: a reach comparison needs the map of the field the skeleton
|
|
1299
|
+
// declares even when that field moves the reading by nothing at all (#427).
|
|
1300
|
+
const all: DialProbe[] = [];
|
|
898
1301
|
for (const field of DIAL_FIELDS) {
|
|
899
1302
|
const step = dialStep(field);
|
|
900
1303
|
skeleton.setupPose();
|
|
901
1304
|
const base = bone.pose[field];
|
|
902
1305
|
const v0 = dialValue(skeleton, slider, bone, field, base);
|
|
903
1306
|
const v1 = dialValue(skeleton, slider, bone, field, base + step);
|
|
904
|
-
|
|
905
|
-
return { slider, reach: reach(field), field, u0: base, v0, u1: base + step, v1 };
|
|
1307
|
+
all.push({ field, response: Math.abs(v1 - v0), u0: base, v0, u1: base + step, v1 });
|
|
906
1308
|
}
|
|
1309
|
+
const probes = all.filter((p) => Number.isFinite(p.response) && p.response !== 0);
|
|
907
1310
|
// Nothing moves it: a bone another constraint pins, or a reader that cannot see
|
|
908
1311
|
// this bone at all. A37 owns the `scale: 0` shape of the same silence.
|
|
909
|
-
return null;
|
|
1312
|
+
if (probes.length === 0) return null;
|
|
1313
|
+
// ⚠️ A stable sort on a strict comparison, so an exact tie keeps `DIAL_FIELDS`'s
|
|
1314
|
+
// order and the tie is DETECTED below rather than decided here. A comparator
|
|
1315
|
+
// that broke ties would put the arbitrary choice back where nothing sees it.
|
|
1316
|
+
probes.sort((a, b) => b.response - a.response);
|
|
1317
|
+
const winner = probes[0];
|
|
1318
|
+
const stated = readerField(slider.property);
|
|
1319
|
+
const leaders = probes.filter((p) => p.response * DIAL_PROBE_MARGIN >= winner.response);
|
|
1320
|
+
const decisive = leaders.length === 1;
|
|
1321
|
+
const chosen = decisive || stated === null ? winner : (leaders.find((p) => p.field === stated) ?? winner);
|
|
1322
|
+
const verdict: DialVerdict = chosen.field === stated ? (decisive ? 'agreed' : 'settled') : 'disagreed';
|
|
1323
|
+
const discovery: DialDiscovery = {
|
|
1324
|
+
stated,
|
|
1325
|
+
reader: slider.property.constructor.name,
|
|
1326
|
+
drive: chosen.field,
|
|
1327
|
+
driveResponse: chosen.response,
|
|
1328
|
+
rivals: leaders.filter((p) => p.field !== chosen.field).map((p) => ({ field: p.field, response: p.response })),
|
|
1329
|
+
statedResponse:
|
|
1330
|
+
stated === null || stated === chosen.field ? null : (all.find((p) => p.field === stated)?.response ?? 0),
|
|
1331
|
+
verdict,
|
|
1332
|
+
};
|
|
1333
|
+
// 🔒 A tie and a disagreement are built as two different things, because they
|
|
1334
|
+
// ARE two different things: a tie has one belief the artifact broke, and a
|
|
1335
|
+
// disagreement has two that have to be compared. Folding them into one record
|
|
1336
|
+
// with a `verdict` field is how a report comes to say "disagreed" about
|
|
1337
|
+
// legitimate geometry (issues #419, #427).
|
|
1338
|
+
const statedMap = stated === null ? null : (all.find((p) => p.field === stated) ?? null);
|
|
1339
|
+
const tie: DeformDialTie | null =
|
|
1340
|
+
verdict !== 'settled'
|
|
1341
|
+
? null
|
|
1342
|
+
: {
|
|
1343
|
+
slider: slider.name,
|
|
1344
|
+
bone: boneName,
|
|
1345
|
+
drive: DIAL_PROPERTY[chosen.field],
|
|
1346
|
+
driveResponse: chosen.response,
|
|
1347
|
+
rivals: discovery.rivals.map((r) => ({ field: DIAL_PROPERTY[r.field], response: r.response })),
|
|
1348
|
+
};
|
|
1349
|
+
const dispute: DeformDialDispute | null =
|
|
1350
|
+
verdict !== 'disagreed'
|
|
1351
|
+
? null
|
|
1352
|
+
: {
|
|
1353
|
+
slider: slider.name,
|
|
1354
|
+
bone: boneName,
|
|
1355
|
+
stated: stated === null ? discovery.reader : DIAL_PROPERTY[stated],
|
|
1356
|
+
statedResponse: discovery.statedResponse,
|
|
1357
|
+
statedReach: statedMap === null ? null : dialReachOf(slider, statedMap),
|
|
1358
|
+
drive: DIAL_PROPERTY[chosen.field],
|
|
1359
|
+
driveResponse: chosen.response,
|
|
1360
|
+
driveReach: dialReachOf(slider, chosen),
|
|
1361
|
+
outside: [],
|
|
1362
|
+
};
|
|
1363
|
+
return {
|
|
1364
|
+
slider,
|
|
1365
|
+
reach: reach(discovery),
|
|
1366
|
+
tie,
|
|
1367
|
+
dispute,
|
|
1368
|
+
field: chosen.field,
|
|
1369
|
+
u0: chosen.u0,
|
|
1370
|
+
v0: chosen.v0,
|
|
1371
|
+
u1: chosen.u1,
|
|
1372
|
+
v1: chosen.v1,
|
|
1373
|
+
statedMap: dispute === null ? null : statedMap,
|
|
1374
|
+
};
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
/**
|
|
1378
|
+
* The part of an animation's own `0..duration` one field of the driving bone can
|
|
1379
|
+
* select, or `null` when it can select none of it (issue #427).
|
|
1380
|
+
*
|
|
1381
|
+
* ⭐ **Closed form, and exact against the thing that decides.** `poseDial` refuses
|
|
1382
|
+
* outright a drive whose magnitude exceeds `DIAL_DRIVE_LIMIT`, and the two maps
|
|
1383
|
+
* between a field and a time are both affine — the probe's `field -> value`, and
|
|
1384
|
+
* `Slider.update`'s own `value -> time` inverted. So the times a *settable* value
|
|
1385
|
+
* of this field asks for are an interval, and its two ends are `±DIAL_DRIVE_LIMIT`
|
|
1386
|
+
* put through both. A field that moves the reading by nothing gives a single
|
|
1387
|
+
* point, which is the honest answer for it: the setup time and no other.
|
|
1388
|
+
*
|
|
1389
|
+
* ⚠️ It is a statement about what can be **asked for**, not about what the runtime
|
|
1390
|
+
* then does with it — `FromRotate`'s `[0, 360)` wrap can refuse a time this
|
|
1391
|
+
* interval contains. That is why the frames a disputed dial could not have posed
|
|
1392
|
+
* are POSED rather than read off here.
|
|
1393
|
+
*/
|
|
1394
|
+
function dialReachOf(slider: SliderData, map: DialProbe): DialSpan | null {
|
|
1395
|
+
const timeAt = (u: number): number => {
|
|
1396
|
+
const value = map.v0 + ((u - map.u0) * (map.v1 - map.v0)) / (map.u1 - map.u0);
|
|
1397
|
+
return slider.offset + (value - slider.property.offset) * slider.scale;
|
|
1398
|
+
};
|
|
1399
|
+
const ends = [timeAt(-DIAL_DRIVE_LIMIT), timeAt(DIAL_DRIVE_LIMIT)];
|
|
1400
|
+
if (!ends.every((t) => Number.isFinite(t))) return null;
|
|
1401
|
+
const lo = Math.max(0, Math.min(ends[0], ends[1]));
|
|
1402
|
+
const hi = Math.min(slider.animation.duration, Math.max(ends[0], ends[1]));
|
|
1403
|
+
return lo <= hi ? { lo, hi } : null;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
/**
|
|
1407
|
+
* The clause the frame line carries when the two answers did not simply agree —
|
|
1408
|
+
* empty on every rig where they did, which is every one in the gallery.
|
|
1409
|
+
*
|
|
1410
|
+
* ⭐ It names both answers and both responses, because "the probe and the file
|
|
1411
|
+
* disagree" without the numbers is a sentence an author cannot act on.
|
|
1412
|
+
*/
|
|
1413
|
+
function dialDiscoveryClause(discovery: DialDiscovery, bone: string): string {
|
|
1414
|
+
if (discovery.verdict === 'agreed') return '';
|
|
1415
|
+
const drive = `${bone}.${DIAL_PROPERTY[discovery.drive]} ${discovery.driveResponse.toExponential(3)}`;
|
|
1416
|
+
if (discovery.verdict === 'settled') {
|
|
1417
|
+
const rivals = discovery.rivals
|
|
1418
|
+
.map((r) => `${bone}.${DIAL_PROPERTY[r.field]} ${r.response.toExponential(3)}`)
|
|
1419
|
+
.join(', ');
|
|
1420
|
+
return (
|
|
1421
|
+
`, driven through ${bone}.${DIAL_PROPERTY[discovery.drive]} — the probe did not settle that on its own ` +
|
|
1422
|
+
`(${drive} against ${rivals}, inside the ${DIAL_PROBE_MARGIN}x margin), so the skeleton's own reader broke ` +
|
|
1423
|
+
'the tie'
|
|
1424
|
+
);
|
|
1425
|
+
}
|
|
1426
|
+
const statedName = discovery.stated === null ? discovery.reader : `${bone}.${DIAL_PROPERTY[discovery.stated]}`;
|
|
1427
|
+
const statedAt =
|
|
1428
|
+
discovery.statedResponse === null ? 'which this file does not know' : `which moves it by ${discovery.statedResponse.toExponential(3)}`;
|
|
1429
|
+
return (
|
|
1430
|
+
`, driven through ${bone}.${DIAL_PROPERTY[discovery.drive]} — the probe moves the reading by ${drive} while ` +
|
|
1431
|
+
`the skeleton says the reader is ${statedName}, ${statedAt}. The two disagree and both are reported: the ` +
|
|
1432
|
+
'drive is the one that measurably moves the reading'
|
|
1433
|
+
);
|
|
910
1434
|
}
|
|
911
1435
|
|
|
912
1436
|
/**
|
|
@@ -996,7 +1520,24 @@ function poseDial(data: SkeletonData, plan: DialPlan, time: number): PoseOfFrame
|
|
|
996
1520
|
};
|
|
997
1521
|
};
|
|
998
1522
|
/** The affine first guess, and what it is judged against. */
|
|
999
|
-
const
|
|
1523
|
+
const asked = plan.u0 + ((value - plan.v0) * (plan.u1 - plan.u0)) / (plan.v1 - plan.v0);
|
|
1524
|
+
// 🚨 The bound, and it is on the drive the answer is READ off, not on the
|
|
1525
|
+
// arithmetic that got there (issue #419). Two different things wanted bounding
|
|
1526
|
+
// and they want it differently:
|
|
1527
|
+
//
|
|
1528
|
+
// - **the mapping's own ask.** A map measured through a field the reading
|
|
1529
|
+
// barely moves inverts to nonsense — this is #419's own case, where a world
|
|
1530
|
+
// scale solved through `rotation` asked for 4.9e9 — and that is the frame
|
|
1531
|
+
// the report would print as advice. Out of bounds, it is not posed at all:
|
|
1532
|
+
// the bone stays at the setup value of its field, the key is unreachable,
|
|
1533
|
+
// and `unreachableWhy` names the ask and the bound.
|
|
1534
|
+
// - **a secant step.** The loop below is a search and a search may step
|
|
1535
|
+
// anywhere; a step out of bounds is a step this stops taking, not a verdict.
|
|
1536
|
+
// `FromRotate`'s wrap sends one there routinely on a rig whose real defect is
|
|
1537
|
+
// the wrap, and reporting the bound instead of the wrap would swap a
|
|
1538
|
+
// diagnosis for a symptom.
|
|
1539
|
+
const runaway = !Number.isFinite(asked) || Math.abs(asked) > DIAL_DRIVE_LIMIT;
|
|
1540
|
+
const first = runaway ? plan.u0 : asked;
|
|
1000
1541
|
const near = 1e-9 * (1 + Math.abs(value));
|
|
1001
1542
|
let u = first;
|
|
1002
1543
|
let got = at(u);
|
|
@@ -1008,11 +1549,11 @@ function poseDial(data: SkeletonData, plan: DialPlan, time: number): PoseOfFrame
|
|
|
1008
1549
|
// on and a wandered secant point is not.
|
|
1009
1550
|
let pu = first === plan.u0 ? plan.u1 : plan.u0;
|
|
1010
1551
|
let pv = Number.NaN;
|
|
1011
|
-
for (let step = 0; step < DIAL_SOLVE_STEPS && Math.abs(got.read - value) > near; step++) {
|
|
1552
|
+
for (let step = 0; !runaway && step < DIAL_SOLVE_STEPS && Math.abs(got.read - value) > near; step++) {
|
|
1012
1553
|
if (!Number.isFinite(pv)) pv = at(pu).read;
|
|
1013
1554
|
if (pv === got.read || !Number.isFinite(pv) || !Number.isFinite(got.read)) break;
|
|
1014
1555
|
const next = u + ((value - got.read) * (u - pu)) / (got.read - pv);
|
|
1015
|
-
if (!Number.isFinite(next)) break;
|
|
1556
|
+
if (!Number.isFinite(next) || Math.abs(next) > DIAL_DRIVE_LIMIT) break;
|
|
1016
1557
|
pu = u;
|
|
1017
1558
|
pv = got.read;
|
|
1018
1559
|
u = next;
|
|
@@ -1027,9 +1568,13 @@ function poseDial(data: SkeletonData, plan: DialPlan, time: number): PoseOfFrame
|
|
|
1027
1568
|
dial: {
|
|
1028
1569
|
value,
|
|
1029
1570
|
driven: u,
|
|
1571
|
+
beyondLimit: runaway ? asked : null,
|
|
1030
1572
|
applied: got.applied,
|
|
1031
1573
|
wanted,
|
|
1032
|
-
unreachable
|
|
1574
|
+
// ⚠️ A drive the bound refused is unreachable WHATEVER the runtime then
|
|
1575
|
+
// landed on. Nothing was posed where the mapping asked, so a time that
|
|
1576
|
+
// happens to match is a coincidence of the setup pose and not a measurement.
|
|
1577
|
+
unreachable: runaway || !(Math.abs(got.applied - wanted) <= DIAL_TIME_EPSILON),
|
|
1033
1578
|
},
|
|
1034
1579
|
};
|
|
1035
1580
|
}
|