spine-rigc 1.5.0 โ†’ 1.5.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/docs/AUTHORING.md CHANGED
@@ -1298,6 +1298,14 @@ emitted as written, in the default skin or anywhere else.
1298
1298
  `spec` must be exactly `"rigc-rig/1"`. `name` must be a non-empty string. `bones`
1299
1299
  must be non-empty. `slots` must be present (it may be empty).
1300
1300
 
1301
+ ๐Ÿ”ข **Every number in a rig spec is finite at float32 precision** โ€” at most
1302
+ ยฑ3.4028234663852886e38 โ€” whatever field it is in, so the ranges the tables below
1303
+ state are ranges *within* that. The skeleton is written as float32, and a number
1304
+ past that line would be emitted as Infinity, which JSON writes as `null`: `1e309`
1305
+ (which JSON reads as Infinity) and `1e308` (a finite double with no float32) are
1306
+ both refused by the field they are in, before anything is compiled (ยง5.1). The cut
1307
+ manifest and the motion spec are held to the same line.
1308
+
1301
1309
  ๐Ÿšซ **Every example value below is invented.** Names, coordinates, vertex lists and
1302
1310
  payloads in this guide are written to illustrate a field, never copied out of a
1303
1311
  reference export โ€” an example lifted from one would be handing an authoring agent an
@@ -5429,11 +5437,14 @@ corpus inventory, and a ladder run reads this guide without following its refere
5429
5437
  out of it.)
5430
5438
 
5431
5439
  ๐Ÿ”ข **A fourth surface is a pose that is not finite**, which `render` and `check`
5432
- refuse before drawing anything, naming the bone or vertex and its value. `validate`
5433
- does not always get there first: a bone `rotation` of `1e309`, in the setup pose or
5434
- in a key, was measured leaving `A10_NO_NAN_AFTER_STEPPING` green, because the bone's
5435
- world position stays finite while its matrix is `NaN`. The sentence, and where to
5436
- look for the number, is under *nothing to draw* in ยง3.4.
5440
+ refuse before drawing anything, naming the bone or vertex and its value.
5441
+ `A10_NO_NAN_AFTER_STEPPING` reads the same scan, so on a skeleton with an animation
5442
+ `validate` gets there first and in the same words: a bone `rotation` of `1e309`, in
5443
+ the setup pose or in a key, leaves the bone's world position finite and its matrix
5444
+ `NaN`, and A10 names the bone and the term. What it does not see is a skeleton with
5445
+ no animation (A10 SKIPs, so `render` is where that one is named) and a skin other
5446
+ than the default, which A10 does not pose. The sentence, and where to look for the
5447
+ number, is under *nothing to draw* in ยง3.4.
5437
5448
 
5438
5449
  ### 5.1 Compile errors โ€” before the gate
5439
5450
 
@@ -5558,6 +5569,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
5558
5569
  | `a rig spec needs a non-empty "bones" array` | ยง3.2 โ€” a rig with no bones has nothing to hang a slot on; every one has at least a root |
5559
5570
  | `a rig spec needs a "slots" array (it may be empty; its ORDER is the draw order)` | ยง3.3 โ€” write `[]` for a rig that draws nothing. โš ๏ธ These three arrive only when the key is really absent: **misspelt**, it is the unknown-key refusal above, naming what you wrote |
5560
5571
  | `bone "X" names parent "Y", which is not declared before it` | move `Y` earlier in `bones` |
5572
+ | `<file>: <where> is Infinity; a number in this file is finite at float32 precision, at most ยฑ3.4028234663852886e+38, because the skeleton is written as float32 โ€” โ€ฆ` (also `is -Infinity`, `is 1e+308`, and `is NaN` from a library caller) | ยง3 โ€” state the number you meant, inside float32's range. One walk over the whole file, run right after the key scan and before any other rig-spec check, so a range rule never prints `NaN`. `<where>` is every number in the rig spec โ€” a bone (`bone "hip" x`), a slot, a skin's attachment down to its arrays (`skin "default" slot "s" attachment "a" weights[3][1].x`), a constraint (`constraint "aim" mixRotate`), an event, `skeleton`, `invariants.meshTriangles` โ€” and the dotted path in the cut manifest (`crop.h`) and in the motion spec (`` `animations.idle.tracks[0].keys[1].v[0]` ``, after the motion spec's own field checks, which keep their sentences). โš ๏ธ It sees numbers only: `"x": "NaN"` is a string and is not refused by this row โ€” write a number as a JSON number |
5561
5573
  | `two bones are called "X"` | bone names are the join key; rename one |
5562
5574
  | `two ik constraints are called "X" โ€” a constraint resolves by name AND type (\`SkeletonData.findConstraint\`), so names are unique PER KIND: an ik and a transform constraint may share one, two of a kind may not` | ยง3.5 โ€” rename one of the two. The kind in the sentence is the pair's own, so `two transform constraints are called "X"` is the same refusal on another kind; a name shared **across** kinds is not this error |
5563
5575
  | `physics constraint "X" is declared in both the rig spec and the motion spec's physics table` | ยง4.6 โ€” the rig spec declares a physics constraint's structure and the motion spec's `physics` table declares one outright; pick the file it belongs in. Per kind, like every other constraint name: an `ik` "X" in the rig spec beside a `physics` "X" here is two constraints and is not this error |
@@ -5783,7 +5795,7 @@ Fix A00 and run it again.
5783
5795
  | `A07_ATLAS_TEXT_SHAPE` | both | atlas text: a region name with stray whitespace, or a blank line splitting a page block. rigc writes the atlas, so this means a hand-edited file. Three sentences are about blank lines and name three different repairs: `line 1: the file begins with a blank line` (or `with N blank lines` for a run, one finding) is a blank before the first page name โ€” delete it; `line N: the file ends with a blank line` (or `with N blank lines`, one finding) is a blank after the last page block's final line โ€” delete it, since rigc ends the file on one newline; `line N: consecutive blank lines` is two blank lines side by side between the first page and the last โ€” delete one. None of the three is `the last page block declares no region`, which means a page name and its fields with no region after them. rigc writes none of the three: under `--atlas-in` the pack's blank lines are put in rigc's shape on re-emission (ยง0.2), so any of them means a file rigc did not write, handed to `validate`. โš ๏ธ An atlas with **no page block at all** โ€” no non-blank line โ€” is not one of those: its subject is absent, so this reports **SKIP** naming the byte count it read, and so do the four rules below whose subject is a page ([#608](https://github.com/firejune/rigc/issues/608)). A rig whose skins need no art writes exactly that file (ยง3.4), and before #608 this row refused it with two findings naming a page block that was not there. What an empty atlas does **not** excuse is an attachment that wants a region out of it โ€” that is `A08` |
5784
5796
  | `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both | three things, and the message says which: an attachment whose `path` names **no region** of this atlas; a `path` carrying **stray whitespace**, printed quoted so you can see it; an **atlas region name** carrying stray whitespace (`A07` names that same line with its line number). The first two are read off the raw file **before** the loader is asked, so the miss is named here with the skin, the slot, the placeholder and the attachment's own name โ€” the four things `AtlasAttachmentLoader`'s own `Region not found in atlas: <path> (attachment: <name>)` does not carry. There is no `spine-html` clause here โ€” a placeholder is free to differ from the region its `path` names **SKIP** when no attachment names a region *and* the atlas declares none โ€” both of its subjects at once |
5785
5797
  | `A09_ANIMATION_DURATION_MATCHES_SPEC` | both | the loaded duration โ‰  the declared one, or the two sides disagree about which animations exist (R7). Asymmetric by design: a frame of slack for an animation that ends early, and none worth the name for a key *past* the declared end, which is the same rule ยง4.5 states at compile time โ€” held here against a skeleton the compiler never saw. **SKIP** when neither side has an animation at all โ€” a static rig has no duration |
5786
- | `A10_NO_NAN_AFTER_STEPPING` | both | stepping the animation produced a `NaN` pose. Look for a degenerate curve or a zero scale. ๐Ÿฆด **It also poses every bone `inherit` key at its own time**: the runtime resolves a mode by folding the case of its first letter and nothing else, and a spelling that misses is stored as **NaN** โ€” the world position stays finite, `updateWorldTransform` matches no mode, and the bone keeps the rotation, scale and shear it had. The detail names the animation, the bone, the key's time and its spelling, beside the five. A bone whose *setup* spelling misses poses no mode at all, and is named the same way from the stepping loop. Neither is reachable from a rig spec โ€” `build` refuses both spellings by name (ยง5.1) โ€” so on a green build this clause is about files rigc did not write. Which mode a correct spelling poses is not judged here, because the lookup that resolved it is the one that would be checked **SKIP** when the skeleton carries no animation: the NaN is produced by stepping, and a static rig is never stepped โ€” the same subject `A09` skips on |
5798
+ | `A10_NO_NAN_AFTER_STEPPING` | both | stepping the animation produced a pose that is not finite. It reads the **whole world transform** โ€” all six terms `a`, `b`, `c`, `d`, `worldX`, `worldY` of every bone โ€” then every vertex of every region and mesh the slots show, at the **setup pose** and at every stepped frame of every animation, through the same scan `render` refuses on, so the two cannot disagree. The detail names where (`the setup pose`, or `animation "x" frame i of 120 (t=โ€ฆs)`), then the bone and the term (`bone "x" has a NaN`), or the slot, attachment and vertex (`vertex 3 has x Infinity`) when every bone is finite and a vertex is not โ€” a `scaleX` chain whose product stays under the largest double does that. A bone at `rotation: 1e309` has a finite position and a `NaN` matrix, which is why the position alone is not read. Look for a number past the largest double, a degenerate curve or a zero scale. ๐Ÿฆด **It also poses every bone `inherit` key at its own time**: the runtime resolves a mode by folding the case of its first letter and nothing else, and a spelling that misses is stored as **NaN** โ€” the world position stays finite, `updateWorldTransform` matches no mode, and the bone keeps the rotation, scale and shear it had. The detail names the animation, the bone, the key's time and its spelling, beside the five. A bone whose *setup* spelling misses poses no mode at all, and is named the same way from the stepping loop. Neither is reachable from a rig spec โ€” `build` refuses both spellings by name (ยง5.1) โ€” so on a green build this clause is about files rigc did not write. Which mode a correct spelling poses is not judged here, because the lookup that resolved it is the one that would be checked **SKIP** when the skeleton carries no animation: the NaN is produced by stepping, and a static rig is never stepped โ€” the same subject `A09` skips on |
5787
5799
  | `A11_NO_CLIPPING_ATTACHMENTS` | renderer | a clipping attachment; the target renderer skips them silently |
5788
5800
  | `A12_NO_DARK_COLOR` | renderer | a slot `dark` colour or an `rgba2`/`rgb2` timeline; parsed, then ignored |
5789
5801
  | `A13_MESH_BUDGET` | renderer | more mesh slots than the rig's `invariants.meshSlots`, or a mesh over its `invariants.meshTriangles`. Thin the mesh, or raise the budget in the rig spec. **SKIP** when the rig declares neither โ€” which means *unmeasured*, not that the budget is inert: the same `meshSlots` is a **compile-time** refusal for rigc's own generators, before the gate (ยง3.7) **SKIP** also when the rig budgets **only** triangles and the skeleton carries no mesh. A declared slot budget still PASSes there, because zero mesh slots is a count measured against a ceiling |
@@ -8840,8 +8852,8 @@ Found within 2 px of the truth, out of 15 trials per cell (free search, default
8840
8852
 
8841
8853
  | texture level (mean) | 24 px | 32 px | 48 px | 64 px | 96 px | 128 px | 192 px |
8842
8854
  | --- | --- | --- | --- | --- | --- | --- | --- |
8843
- | `native` (detail 3.49) | 13/15 (2 amb) | 15/15 | 15/15 | 15/15 | 13/15 (1 amb, 1 wrong) | 14/15 (1 wrong) | 14/15 (1 wrong) |
8844
- | `blur1` (detail 1.58) | 12/15 (3 amb) | 12/15 (2 amb, 1 wrong) | 13/15 (2 amb) | 12/15 (3 amb) | 12/15 (2 amb, 1 wrong) | 11/15 (1 amb, 3 wrong) | 11/15 (2 amb, 2 wrong) |
8855
+ | `native` (detail 3.49) | 13/15 (2 amb) | 15/15 | 15/15 | 15/15 | 14/15 (1 wrong) | 14/15 (1 wrong) | 14/15 (1 wrong) |
8856
+ | `blur1` (detail 1.58) | 12/15 (3 amb) | 12/15 (3 amb) | 13/15 (2 amb) | 12/15 (3 amb) | 12/15 (2 amb, 1 wrong) | 11/15 (1 amb, 3 wrong) | 11/15 (2 amb, 2 wrong) |
8845
8857
  | `blur2` (detail 0.59) | 9/15 (5 amb, 1 wrong) | 9/15 (4 amb, 2 wrong) | 9/15 (4 amb, 2 wrong) | 8/15 (3 amb, 4 wrong) | 7/15 (3 amb, 5 wrong) | 6/15 (4 amb, 5 wrong) | 7/15 (3 amb, 5 wrong) |
8846
8858
  | `flat` (detail 0.00) | 0/15 (14 amb, 1 wrong) | 0/15 (14 amb, 1 wrong) | 0/15 (13 amb, 2 wrong) | 0/15 (11 amb, 4 wrong) | 0/15 (13 amb, 2 wrong) | 0/15 (12 amb, 3 wrong) | 0/15 (12 amb, 3 wrong) |
8847
8859
 
@@ -8850,14 +8862,14 @@ The same trials, grouped by `detail`:
8850
8862
  | detail | found | ambiguous | wrong | refused |
8851
8863
  | --- | --- | --- | --- | --- |
8852
8864
  | under 0.25 | 8/119 | 89 | 22 | 0 |
8853
- | 0.25โ€“0.5 | 1/3 | 1 | 1 | 0 |
8854
- | 0.5โ€“0.75 | 48/81 | 14 | 19 | 0 |
8865
+ | 0.25โ€“0.5 | 0/3 | 2 | 1 | 0 |
8866
+ | 0.5โ€“0.75 | 49/81 | 13 | 19 | 0 |
8855
8867
  | 0.75โ€“1 | 17/34 | 14 | 3 | 0 |
8856
8868
  | 1โ€“1.5 | 25/25 | 0 | 0 | 0 |
8857
- | 1.5โ€“2 | 34/48 | 12 | 2 | 0 |
8869
+ | 1.5โ€“2 | 34/48 | 13 | 1 | 0 |
8858
8870
  | 2โ€“3 | 40/44 | 1 | 3 | 0 |
8859
8871
  | 3โ€“4 | 34/34 | 0 | 0 | 0 |
8860
- | 4 and over | 30/32 | 2 | 0 | 0 |
8872
+ | 4 and over | 31/32 | 1 | 0 | 0 |
8861
8873
 
8862
8874
  **Method.** The truth set is `examples/spineboy`: its setup pose and one frame of
8863
8875
  `walk` (a third of the way in). For every trial the figure is rendered twice with one
@@ -8879,8 +8891,8 @@ part has a low per-pixel gradient and still places โ€” and size alone has no
8879
8891
  measurable effect across 24โ€“192 px at any level.
8880
8892
 
8881
8893
  **The edge**, derived by `deriveFloor` as the largest rung of a fixed ladder under
8882
- which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **7.4%**
8883
- of the trials were found (9/122); at or over it, **76.5%** (228/298). A part is below
8894
+ which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **6.6%**
8895
+ of the trials were found (8/122); at or over it, **77.2%** (230/298). A part is below
8884
8896
  the floor when its `detail` is under 0.5, or when its own longest side is under 24 px,
8885
8897
  the smallest size measured (there, nothing measured says `pose` can place it).
8886
8898
 
@@ -8911,16 +8923,29 @@ blurred part now reported with a second placement at the same spot and another
8911
8923
  scale, one of them with the truth itself as its best answer), for 43% more
8912
8924
  refinement samples and the coarse pass untouched.
8913
8925
 
8914
- โš ๏ธ **What this is and is not.** The grid is not a reliability line: five native cells
8915
- place 14 or 15 of 15 and the trials at `detail` 3 and over place 64 of 66, but the
8916
- goggles still place 12 of 42, and a threshold read off two dozen parts of one figure
8926
+ Since issue #886 the stopped polish tries **a quarter rung** (2^(1/12)) beside the
8927
+ half and keeps the better re-fit. The case was not a grid trial but ยง6's stationary
8928
+ post in [MOTION.md](MOTION.md): under `--scale 0.85,1.2` it came back at scale
8929
+ 0.917, residual 0.0649, where the old placement at 0.975 still scores 0.0589. The
8930
+ half rung stepped from 0.917 to 1.029, where the best position scores 0.0980 โ€” a part
8931
+ that fills its image pays for every pixel pushed past the figure, so above its truth
8932
+ the objective is a cliff, and half a rung crossed the basin onto it. With both, the
8933
+ post places at 0.971 (0.0593, 0.1 px from the old placement), and the grid goes from
8934
+ 237 to 238 found and 8 to 6 misses (2 gained, 1 lost) for 16% more user CPU. The
8935
+ quarter alone found 234, losing the native arms #877 was made for. The one lost is a
8936
+ margin reading again: the blurred 32 px front shin (walk) now reports its truth as its
8937
+ best (0.07 px, 0.00968), with its old 0.51 px answer 0.0045 above it.
8938
+
8939
+ โš ๏ธ **What this is and is not.** The grid is not a reliability line: six native cells
8940
+ place 14 or 15 of 15 and the trials at `detail` 3 and over place 65 of 66, but the
8941
+ goggles still place 13 of 42, and a threshold read off two dozen parts of one figure
8917
8942
  would be a claim about that figure. What the grid can separate is **whose** failure a
8918
8943
  failure is. `bun tools/pose_floor.ts` prints the residual at the truth for every
8919
8944
  failed trial and marks it **missed** when the truth scores better than everything
8920
8945
  reported and nothing reported is on it โ€” a failure the search could fix. The other
8921
8946
  failures are the objective preferring another placement (a blurred part shrunk into
8922
8947
  its own region scores better than its truth) or the truth reported and tied, and no
8923
- search can fix those. After #877, 8 of the 183 failed trials are misses, and 7 of
8948
+ search can fix those. After #886, 6 of the 182 failed trials are misses, and 5 of
8924
8949
  those sit within 3.1 px of the truth rather than on another hill; the `--from`
8925
8950
  listing prints, for each, where the refinement left the truth level by level. All this was
8926
8951
  measured on **ideal cuts**, the frame's own pixels, where a residual at the truth is
@@ -8935,7 +8960,8 @@ print the table, derive the edge and list the misses per cell. `PO22` in
8935
8960
  no miss at all, and `PO23` places the 48 px fist #865 was made for; `PO28` and
8936
8961
  `PO29` place the four trials #877 was made for and read where the shin survived the
8937
8962
  cutoff, and `PO26` holds every polish to strictly descending residuals, read through
8938
- the same trace.
8963
+ the same trace. `PO31` places ยง6's post at the old placement from the page's own
8964
+ input bytes, and `PO32` holds it to taking the quarter-rung escape and reading the same twice.
8939
8965
 
8940
8966
  ## 12. Reading the half of that picture `pose` refuses โ€” `rigc chainfit`
8941
8967
 
package/docs/INGEST.md CHANGED
@@ -370,6 +370,8 @@ rigc check --candidate examples/3-timing-and-spacing/export/3-timing-and-spacing
370
370
  --frames ref3/light
371
371
  ```
372
372
 
373
+ **No run reproduces this:** abridged โ€” the head's `atlas`, `frames`, `skin`, `scope`, `reference`, `content` and `in units` lines and its two โš ๏ธ notes are cut, so the `โคท fit` line the run prints under `content` stands under `framed to`; the section's `frames` line and every `โคท` note are cut; and so is everything the run prints after `per-frame`
374
+
373
375
  ```
374
376
  rigc check
375
377
  candidate โ€ฆ/examples/3-timing-and-spacing/export/3-timing-and-spacing-ess.json
@@ -378,14 +380,14 @@ rigc check
378
380
  declared frames.json's own box: TAKEN, coincident โ€” a fit there asks for 0.00 px, under the 1 px that separates a candidate in the frames' coordinates from one in its own, over 21 frame(s).
379
381
 
380
382
  โ”€โ”€ light โ€” candidate animation "light", 12 fps โ”€โ”€
381
- MAE mean 0.00 worst 0.00 at f00-1 (0..255 over the union alpha; over the whole frame, mean 0.00)
382
- slot drift worst 0.5 px "pendulum" at f0003
383
+ MAE mean 0.00 worst 0.00 (exact: none of the 21 compared frame(s) differs from the reference) (0..255 over the union alpha; over the whole frame, mean 0.00)
384
+ slot drift worst 0.4 px "pendulum" at f0012
383
385
  per-frame all 20 adjacent pair(s) change by as much as the reference's own frames do
384
386
  ```
385
387
 
386
388
  Two things to take from the control, both of which you need before reading any real
387
389
  number: the **framing** resolved to the frames' own box at 0.00 px, so the MAE is a
388
- comparison of pictures rather than of framings; and **slot drift still reads 0.5 px at
390
+ comparison of pictures rather than of framings; and **slot drift still reads 0.4 px at
389
391
  MAE 0.00**, which is that instrument's own floor rather than a difference.
390
392
 
391
393
  โš ๏ธ **`--texture-from` is not optional on ingest work, and the reason is structural.**
@@ -1339,7 +1341,9 @@ against one picture. A foreign export hands you a packed page instead, and point
1339
1341
  `pose` at one is worse than useless โ€” it treats the whole page as a single part and
1340
1342
  answers confidently:
1341
1343
 
1342
- ```bash
1344
+ ```bash setup
1345
+ rigc render --candidate examples/3-timing-and-spacing/export/3-timing-and-spacing-ess.json \
1346
+ --fps 4 --max 900 --out ref-big
1343
1347
  mkdir -p packed-only
1344
1348
  cp examples/3-timing-and-spacing/export/3-timing-and-spacing.png packed-only/
1345
1349
  rigc pose --images packed-only --frame 'ref-big/light@4fps/f0002.png'
@@ -1350,12 +1354,12 @@ rigc pose
1350
1354
  .. frame โ€ฆ/ref-big/light@4fps/f0002.png (900x409)
1351
1355
  .. ground rgb(232, 232, 232) over 100% of the border ring
1352
1356
  .. parts โ€ฆ/packed-only (1 png)
1353
- .. search scale 0.5โ€“2 in 7 step(s) ยท rotation -180ยฐโ€“180ยฐ step 15ยฐ ยท refuse above residual 0.25
1354
- PLACE 3-timing-and-spacing.png x= 324.3 y= 188.2 rot= -91.4ยฐ scale=0.629 residual=0.2083 unexplained= 30%
1355
- found on a 7x3 anchor grid, step 4 at 32x reduction
1357
+ .. search scale 0.5โ€“2 in 7 step(s) ยท rotation -180ยฐโ€“180ยฐ in 24 step(s) of 15ยฐ ยท refuse above residual 0.25
1358
+ PLACE 3-timing-and-spacing.png x= 324.3 y= 188.3 rot= -91.6ยฐ scale=0.630 residual=0.2085 unexplained= 30%
1359
+ found on a 14x7 anchor grid, step 4 at 16x reduction
1356
1360
  ```
1357
1361
 
1358
- โš ๏ธ **`residual=0.2083` is *under* the default 0.25 refusal bar**, so nothing refused
1362
+ โš ๏ธ **`residual=0.2085` is *under* the default 0.25 refusal bar**, so nothing refused
1359
1363
  it, and `PLACE` rather than `AMBIG` means nothing flagged it either. With the same frame
1360
1364
  and the two real loose parts, the answer is what it should be:
1361
1365
 
@@ -1365,12 +1369,15 @@ rigc pose --images examples/3-timing-and-spacing/images \
1365
1369
  ```
1366
1370
 
1367
1371
  ```
1372
+ rigc pose
1373
+ .. frame โ€ฆ/ref-big/light@4fps/f0002.png (900x409)
1374
+ .. ground rgb(232, 232, 232) over 100% of the border ring
1368
1375
  .. parts โ€ฆ/examples/3-timing-and-spacing/images (2 png)
1369
- .. search scale 0.3โ€“0.6 in 4 step(s) ยท rotation -180ยฐโ€“180ยฐ step 15ยฐ ยท refuse above residual 0.25
1370
- PLACE pendulum.png x= 319.3 y= 214.5 rot= -88.9ยฐ scale=0.412 residual=0.0425 unexplained= 7%
1371
- found on a 12x5 anchor grid, step 5 at 16x reduction
1372
- PLACE square.png x= 437.2 y= 343.1 rot= -0.1ยฐ scale=0.410 residual=0.0331 unexplained= 4%
1373
- found on a 57x26 anchor grid, step 4 at 4x reduction
1376
+ .. search scale 0.3โ€“0.6 in 4 step(s) ยท rotation -180ยฐโ€“180ยฐ in 24 step(s) of 15ยฐ ยท refuse above residual 0.25
1377
+ PLACE pendulum.png x= 319.3 y= 214.6 rot= -88.9ยฐ scale=0.411 residual=0.0428 unexplained= 7%
1378
+ found on a 28x13 anchor grid, step 2 at 16x reduction
1379
+ PLACE square.png x= 437.1 y= 343.1 rot= 0.0ยฐ scale=0.410 residual=0.0334 unexplained= 4%
1380
+ found on a 113x51 anchor grid, step 2 at 4x reduction
1374
1381
  ```
1375
1382
 
1376
1383
  โ‡’ **Check what is in `--images` before trusting a `pose` report on ingest work.** One
package/docs/MOTION.md CHANGED
@@ -767,7 +767,7 @@ late, ยง3.8's overshoot is live; animation name โ†’ **`raise`**.
767
767
 
768
768
  ### 1. The art, and the two pictures
769
769
 
770
- ```bash
770
+ ```bash setup
771
771
  mkdir -p semaphore/parts && cd semaphore
772
772
  bun -e '
773
773
  const files = {
@@ -806,15 +806,15 @@ rigc pose
806
806
  .. frame โ€ฆ/semaphore/poseA.png (160x200)
807
807
  .. ground rgb(238, 238, 234) over 100% of the border ring
808
808
  .. parts โ€ฆ/semaphore/parts (3 png)
809
- .. search scale 0.5โ€“2 in 7 step(s) ยท rotation -180ยฐโ€“180ยฐ step 15ยฐ ยท refuse above residual 0.25
810
- PLACE arm.png x= 91.9 y= 122.8 rot= 61.9ยฐ scale=0.968 residual=0.0320 unexplained= 4%
811
- found on a 10x13 anchor grid, step 4 at 4x reduction
812
- PLACE flag.png x= 104.7 y= 156.1 rot= 84.3ยฐ scale=0.955 residual=0.0135 unexplained= 0%
809
+ .. search scale 0.5โ€“2 in 7 step(s) ยท rotation -180ยฐโ€“180ยฐ in 24 step(s) of 15ยฐ ยท refuse above residual 0.25
810
+ PLACE arm.png x= 92.0 y= 122.8 rot= 61.9ยฐ scale=0.970 residual=0.0320 unexplained= 4%
813
811
  found on a 20x25 anchor grid, step 4 at 2x reduction
814
- AMBIG post.png x= 79.0 y= 141.7 rot= -0.4ยฐ scale=0.690 residual=0.0687 unexplained= 25%
815
- found on a 7x9 anchor grid, step 3 at 8x reduction
816
- alt 2: x= 78.9 y= 140.7 rot= -0.4ยฐ scale=0.650 residual=0.0692 unexplained= 26%
817
- alt 3: x= 78.4 y= 135.5 rot= -0.5ยฐ scale=0.500 residual=0.0702 unexplained= 23%
812
+ PLACE flag.png x= 104.7 y= 156.1 rot= 84.3ยฐ scale=0.955 residual=0.0135 unexplained= 0%
813
+ found on a 40x50 anchor grid, step 2 at 2x reduction
814
+ AMBIG post.png x= 79.9 y= 149.0 rot= -0.1ยฐ scale=0.971 residual=0.0593 unexplained= 16%
815
+ found on a 14x17 anchor grid, step 6 at 2x reduction
816
+ alt 2: x= 79.0 y= 141.7 rot= -0.5ยฐ scale=0.691 residual=0.0687 unexplained= 25%
817
+ ambiguous: part is 14x96 px (span 93.2 frame px, opaque 1) with texture 0.0146, detail 1.406; 43 candidate(s), best 0.0593, next 0.0687, spread 0.0095 โ€” above the measured floor (AUTHORING ยง11.5: detail 0.5, measured from 24 px up), so size and texture do not explain this; either the frame holds more than one place this part fits, or every candidate missed the true one โ€” a narrower --scale or --rotation window around what you know of the part tells the two apart
818
818
  ```
819
819
 
820
820
  โš ๏ธ **The post came back at scale 0.690 with two alternates trailing it down to 0.500 โ€”
@@ -830,17 +830,31 @@ rigc pose --images parts --frame poseB.png --scale 0.85,1.2 --out poseB.json
830
830
  ```
831
831
 
832
832
  ```
833
- .. search scale 0.85โ€“1.2 in 2 step(s) ยท rotation -180ยฐโ€“180ยฐ step 15ยฐ ยท refuse above residual 0.25
834
- PLACE arm.png x= 91.9 y= 122.8 rot= 61.9ยฐ scale=0.968 residual=0.0320 unexplained= 4%
835
- PLACE flag.png x= 104.7 y= 156.1 rot= 84.3ยฐ scale=0.955 residual=0.0135 unexplained= 0%
836
- PLACE post.png x= 79.9 y= 148.6 rot= -0.1ยฐ scale=0.975 residual=0.0589 unexplained= 16%
833
+ rigc pose
834
+ .. frame โ€ฆ/semaphore/poseA.png (160x200)
835
+ .. ground rgb(238, 238, 234) over 100% of the border ring
836
+ .. parts โ€ฆ/semaphore/parts (3 png)
837
+ .. search scale 0.85โ€“1.2 in 2 step(s) ยท rotation -180ยฐโ€“180ยฐ in 24 step(s) of 15ยฐ ยท refuse above residual 0.25
838
+ PLACE arm.png x= 91.9 y= 122.8 rot= 61.9ยฐ scale=0.970 residual=0.0320 unexplained= 4%
839
+ found on a 20x25 anchor grid, step 4 at 2x reduction
840
+ PLACE flag.png x= 104.7 y= 156.1 rot= 84.1ยฐ scale=0.956 residual=0.0135 unexplained= 0%
841
+ found on a 40x50 anchor grid, step 2 at 2x reduction
842
+ PLACE post.png x= 79.9 y= 148.5 rot= -0.1ยฐ scale=0.971 residual=0.0593 unexplained= 16%
843
+ found on a 14x17 anchor grid, step 6 at 2x reduction
837
844
  ```
838
845
 
839
846
  ```
840
- .. search scale 0.85โ€“1.2 in 2 step(s) ยท rotation -180ยฐโ€“180ยฐ step 15ยฐ ยท refuse above residual 0.25
841
- PLACE arm.png x= 102.6 y= 94.8 rot= -18.2ยฐ scale=0.973 residual=0.0305 unexplained= 4%
842
- PLACE flag.png x= 137.4 y= 86.0 rot= -6.6ยฐ scale=0.956 residual=0.0134 unexplained= 1%
847
+ rigc pose
848
+ .. frame โ€ฆ/semaphore/poseB.png (160x200)
849
+ .. ground rgb(238, 238, 234) over 100% of the border ring
850
+ .. parts โ€ฆ/semaphore/parts (3 png)
851
+ .. search scale 0.85โ€“1.2 in 2 step(s) ยท rotation -180ยฐโ€“180ยฐ in 24 step(s) of 15ยฐ ยท refuse above residual 0.25
852
+ PLACE arm.png x= 102.5 y= 94.8 rot= -18.3ยฐ scale=0.974 residual=0.0305 unexplained= 4%
853
+ found on a 20x25 anchor grid, step 4 at 2x reduction
854
+ PLACE flag.png x= 137.4 y= 86.0 rot= -6.6ยฐ scale=0.956 residual=0.0134 unexplained= 0%
855
+ found on a 40x50 anchor grid, step 2 at 2x reduction
843
856
  PLACE post.png x= 80.0 y= 148.0 rot= 0.0ยฐ scale=0.995 residual=0.0394 unexplained= 9%
857
+ found on a 14x17 anchor grid, step 6 at 2x reduction
844
858
  ```
845
859
 
846
860
  Three things to read out of that pair, none of which is a score:
@@ -1168,7 +1182,7 @@ skeleton โ€” AUTHORING ยง10.3 and PROMPTING clause 4 both price it. Keys are str
1168
1182
  The bytes of `poseA.png` and `poseB.png`, so ยง6 runs end to end. Both are 160ร—200 on a
1169
1183
  flat ground; both are invented.
1170
1184
 
1171
- ```bash
1185
+ ```bash setup
1172
1186
  bun -e '
1173
1187
  const frames = {
1174
1188
  "poseA.png": "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",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.5.0",
3
+ "version": "1.5.1",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, round-trips it through spine-core and named assertions, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/compile.ts CHANGED
@@ -38,7 +38,7 @@ import { parseJsonWithPosition } from './json-position.ts';
38
38
  // The "did you mean" list on a missing atlas region, from the one implementation
39
39
  // of it โ€” the same search serves `refuseUnknownKeys`, and a second copy here with
40
40
  // a threshold edited is how such a pair drifts apart.
41
- import { nearMisses } from './keys.ts';
41
+ import { dottedPath, nearMisses, refuseNumbersTheFileCannotCarry } from './keys.ts';
42
42
  import { inEditorKeyOrder, withoutParserDefaults } from './keyorder.ts';
43
43
  import { EVERY_GLOBAL_PHYSICS, parseMotionSpec } from './motion.ts';
44
44
  import {
@@ -2030,6 +2030,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2030
2030
  // and which refusals it deliberately leaves in this file.
2031
2031
  const motion = parseMotionSpec(readJson<unknown>(motionPath), motionPath);
2032
2032
  const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
2033
+ // The manifest is the record of what was measured, and its numbers become
2034
+ // bone positions and the stage: a `crop.h` of 1e309 built as `"height": null`
2035
+ // before issue #881. The rig spec's walk is `parseRigSpec`'s; this is the
2036
+ // same rule over the other file whose numbers reach the skeleton.
2037
+ if (manifest !== null && manifestPath !== null) refuseNumbersTheFileCannotCarry(manifest, manifestPath, dottedPath);
2033
2038
 
2034
2039
  // The rig spec names its own path in every message `parseRigSpec` throws (its
2035
2040
  // `where` argument, above). The motion spec gets no such treatment below โ€”
package/src/keys.ts CHANGED
@@ -115,3 +115,90 @@ export function refuseUnknownKeys(
115
115
  (known.length === 0 ? 'This shape has no fields at all.' : `Known here: ${[...known].sort().join(', ')}.`),
116
116
  );
117
117
  }
118
+
119
+ /**
120
+ * The largest float32, `3.4028234663852886e38`. The skeleton file is written at
121
+ * float32 precision (`f32` in `src/compile.ts` rounds every emitted number), so
122
+ * this is the largest magnitude a stated number can have and still come out of
123
+ * the emitter as a number.
124
+ */
125
+ export const FLOAT32_MAX = 3.4028234663852886e38;
126
+
127
+ /**
128
+ * Refuse the first number in an input file that the skeleton file cannot carry
129
+ * โ€” one that is not finite, or that is finite only as a double (issue #881).
130
+ *
131
+ * ๐Ÿšจ The defect this closes was a green build. `JSON.parse` reads `1e309` as
132
+ * `Infinity`, the emitter wrote it, and `JSON.stringify(Infinity)` is `null`:
133
+ * a bone stated at `x: 1e309` built green on the overlay probe as `"x": null`
134
+ * and was read as 0, a value the spec never stated. `1e308` did exactly the same
135
+ * โ€” it is a finite double, but every emitted number is rounded to a float32 and
136
+ * `Math.fround(1e308)` is `Infinity` โ€” so the line is float32's range and not
137
+ * the double's: `3.4028234663852886e38` built green and was emitted as itself,
138
+ * the next double up that rounds past it (`3.4028235677973366e38`) was emitted
139
+ * as `null`. The predicate is therefore `Number.isFinite(Math.fround(n))`,
140
+ * which is `NaN`, both infinities and exactly that overflow.
141
+ *
142
+ * โญ It walks the file, not the emitter's route โ€” `checkRigSpecKeys`'s design
143
+ * and its reason. Before this, a handful of readers in `compile.ts` carried a
144
+ * finite check of their own (vertex arrays, a path's lengths, a segments
145
+ * falloff, a slider's mapping, an event's payload), each right about its own
146
+ * field, and none of them was a rule. Measured when this was written, planting
147
+ * `1e309` at every numeric leaf of nine rig specs โ€” 610 plants over 57 kinds
148
+ * of field: 19 kinds were refused by a rule of their own (30 of those 70
149
+ * refusals printing the Infinity as `null`, and some only by a later symptom
150
+ * such as *hull Infinity disagrees with the triangles*), 1 was caught only by
151
+ * the gate, and 37 built green with a `null` in them on at least one rig. One
152
+ * pass over every number the document holds cannot miss a reader added next
153
+ * month, and it runs before any of them โ€” so a range rule further down (a
154
+ * radius that is positive, a weight in 0..1) never has to print `NaN`.
155
+ *
156
+ * ๐Ÿ”’ The report order is fixed: `Object.keys` of parsed JSON is the
157
+ * document's key order (integer-like keys first, as the language orders them)
158
+ * and arrays are visited by index, so the refusal names the same number on
159
+ * every run.
160
+ *
161
+ * โš ๏ธ What it cannot see is a number spelled as something else. `"x": "NaN"`
162
+ * is a string; there is no number here to refuse, and the reader downstream
163
+ * that coerces it is a type question this walk does not answer.
164
+ *
165
+ * `place` turns a path into the words the refusal uses for it.
166
+ */
167
+ export function refuseNumbersTheFileCannotCarry(
168
+ raw: unknown,
169
+ where: string,
170
+ place: (path: ReadonlyArray<string | number>) => string,
171
+ ): void {
172
+ const visit = (node: unknown, path: Array<string | number>): void => {
173
+ if (typeof node === 'number') {
174
+ if (Number.isFinite(Math.fround(node))) return;
175
+ const why = Number.isNaN(node)
176
+ ? ''
177
+ : Number.isFinite(node)
178
+ ? ' This one is finite as a double and has no float32 but Infinity.'
179
+ : ' JSON has no spelling for Infinity: a literal past ยฑ1.7976931348623157e308, such as 1e309, parses to it.';
180
+ throw new CompileError(
181
+ `${where}: ${place(path)} is ${String(node)}; a number in this file is finite at float32 precision, at most ` +
182
+ `ยฑ${String(FLOAT32_MAX)}, because the skeleton is written as float32 โ€” past that it would be emitted as ` +
183
+ `Infinity, which JSON writes as null, a value the file never stated.${why}`,
184
+ );
185
+ }
186
+ if (Array.isArray(node)) {
187
+ node.forEach((child, i) => visit(child, [...path, i]));
188
+ } else if (node !== null && typeof node === 'object') {
189
+ const record = node as Record<string, unknown>;
190
+ for (const key of Object.keys(record)) visit(record[key], [...path, key]);
191
+ }
192
+ };
193
+ visit(raw, []);
194
+ }
195
+
196
+ /** A path as the file spells it: `.key`, `."odd key"`, `[3]`. */
197
+ export function dottedPath(path: ReadonlyArray<string | number>): string {
198
+ let out = '';
199
+ for (const step of path) {
200
+ if (typeof step === 'number') out += `[${step}]`;
201
+ else out += /^[A-Za-z_][A-Za-z0-9_]*$/.test(step) ? `${out === '' ? '' : '.'}${step}` : `${out === '' ? '' : '.'}${JSON.stringify(step)}`;
202
+ }
203
+ return out;
204
+ }
package/src/motion.ts CHANGED
@@ -54,7 +54,7 @@
54
54
  * [`keys.ts`](keys.ts) shared with the rig parser.
55
55
  */
56
56
  import { CompileError } from './errors.ts';
57
- import { refuseUnknownKeys } from './keys.ts';
57
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys } from './keys.ts';
58
58
  import { SEQUENCE_MODES } from './timelines.ts';
59
59
  import type { MotionSpec } from './types.ts';
60
60
 
@@ -665,5 +665,11 @@ export function parseMotionSpec(raw: unknown, where: string): MotionSpec {
665
665
  parseAnimation(anim, where, name);
666
666
  }
667
667
 
668
+ // Last, so every field check above keeps its own sentence for a number that
669
+ // is not finite; what reaches this line is a finite double the float32 file
670
+ // cannot carry โ€” a key at 1e308 built green with a `null` in it before issue
671
+ // #881 โ€” and any number in a field no check above reads.
672
+ refuseNumbersTheFileCannotCarry(raw, where, (path) => `\`${dottedPath(path)}\``);
673
+
668
674
  return raw as unknown as MotionSpec;
669
675
  }
package/src/pose.ts CHANGED
@@ -274,8 +274,9 @@ const MINIMA_PER_SCALE = 5;
274
274
  const REFINE_CANDIDATES = 15;
275
275
 
276
276
  /**
277
- * The factor a converged full-resolution polish tries its scale up by before it
278
- * stops โ€” half a coarse scale rung, the resolution the coarse ladder itself had.
277
+ * The factors a converged full-resolution polish tries its scale up by before it
278
+ * stops โ€” half a coarse scale rung, the resolution the coarse ladder itself had,
279
+ * and a quarter of one (issue #886); the better of the two re-fits wins.
279
280
  *
280
281
  * ๐Ÿ” What the pattern search cannot do, and the trace table of issue #877 is
281
282
  * how it was seen: every probe moves ONE degree of freedom, and the objective
@@ -306,8 +307,32 @@ const REFINE_CANDIDATES = 15;
306
307
  * margin, and the blurred 24 px front shin (walk) now places AT its truth
307
308
  * (0.03 px, 0.00989) where it placed 0.57 px off at 0.0191 โ€” the old answer is
308
309
  * still reported beside it, 0.0094 above, inside the 0.01 absolute margin.
310
+ *
311
+ * ๐Ÿ” The quarter rung is issue #886, and the fixed-scale profile is what
312
+ * named it. MOTION.md ยง6's post (14x96, pose A, `--scale 0.85,1.2`) came back
313
+ * at scale 0.917 and residual 0.0649 where the page had recorded 0.975 at
314
+ * 0.0589 โ€” the old placement still scores 0.0589 on this objective, so the
315
+ * search missed it. The trace: the coarse cell nearest the truth (2.4 px off
316
+ * it, at the 2x level) kept a half-turned post, its polish ended 6.7 px from
317
+ * the truth, the full-resolution rotation re-grid set it upright there, and
318
+ * the polish climbed the scaleโ€“position valley to 0.917.
319
+ * The half-rung escape then tried 1.029: best position there 0.0980, worse
320
+ * than 0.0649, so it declined โ€” while the best position at every scale
321
+ * between 0.94 and 1.00 scores 0.0619โ€“0.0589. A part whose material fills its
322
+ * image pays for every pixel pushed past the figure, so its profile above the
323
+ * truth is a cliff, and half a rung stepped over the basin onto it. A quarter
324
+ * rung lands at 0.971 (0.0593, 0.1 px off the page's placement).
325
+ *
326
+ * ๐Ÿ“ Both, not the quarter alone, and measured on the grid rather than argued:
327
+ * the quarter alone found 234 (3 gained, 6 lost โ€” the native arms #877 was
328
+ * made for need the half); half then quarter, the quarter tried only when the
329
+ * half declines, 237 (1, 1); both, the better kept, 238 (2 gained, 1 lost) and
330
+ * misses 8 โ†’ 6, for 16% more user CPU over the grid. The one it loses is a
331
+ * margin reading of the kind named above: the blurred 32 px front shin (walk)
332
+ * now reports its truth as its BEST (0.07 px, 0.00968) with the old 0.51 px
333
+ * answer 0.0045 above it, inside the 0.01 absolute margin.
309
334
  */
310
- const POLISH_SCALE_ESCAPE = 2 ** (1 / (2 * SCALE_STEPS_PER_OCTAVE));
335
+ const POLISH_SCALE_ESCAPES = [2 ** (1 / (4 * SCALE_STEPS_PER_OCTAVE)), 2 ** (1 / (2 * SCALE_STEPS_PER_OCTAVE))];
311
336
 
312
337
  /** Sample budgets per stage. The reported residual uses every pixel regardless. */
313
338
  const COARSE_SAMPLES = 96;
@@ -347,8 +372,8 @@ export const POSE_FLOOR = {
347
372
  span: 24,
348
373
  detail: 0.5,
349
374
  /** Found share of the grid's trials under `detail`, and over it โ€” both measured by `deriveFloor`. */
350
- rateBelow: 0.074,
351
- rateAbove: 0.765,
375
+ rateBelow: 0.066,
376
+ rateAbove: 0.772,
352
377
  };
353
378
 
354
379
  /** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
@@ -1934,7 +1959,7 @@ function placePart(
1934
1959
  }
1935
1960
  const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
1936
1961
  candidates = seeds.map((seed, i) =>
1937
- polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? [POLISH_SCALE_ESCAPE] : [], trace === undefined ? undefined : paths[i]),
1962
+ polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? POLISH_SCALE_ESCAPES : [], trace === undefined ? undefined : paths[i]),
1938
1963
  );
1939
1964
  const polished = candidates.slice();
1940
1965
  candidates.sort((a, b) => a.residual - b.residual);
package/src/render.ts CHANGED
@@ -1288,6 +1288,38 @@ function firstNonFinite(where: string, entries: readonly PosedVertices[], bones:
1288
1288
  return null;
1289
1289
  }
1290
1290
 
1291
+ /**
1292
+ * The same sentence for one skeleton as it stands posed now โ€” its bones, then
1293
+ * the vertices of every region and mesh its slots show, in draw order โ€” or
1294
+ * `null` when every one of those numbers is finite.
1295
+ *
1296
+ * โญ This is how `A10_NO_NAN_AFTER_STEPPING` reads each pose it steps (issue
1297
+ * #882), so the gate and the renderer hold one definition of "not finite": the
1298
+ * six terms `firstNonFinite` reads off a bone, and the vertices the runtime
1299
+ * computes from them. Before it A10 read the world POSITION alone, and a bone at
1300
+ * `rotation: 1e309` โ€” finite position, NaN `a`, `b`, `c`, `d` โ€” was gated green
1301
+ * and then refused here. The vertices are read too because a bone can be finite
1302
+ * and still carry one that is not: a two-bone `scaleX` chain of 1e154 ร— 1e154
1303
+ * leaves the child's `a` at 1e308, finite, and every vertex of its attachment
1304
+ * past the largest double. Only computed once every bone is finite, so a
1305
+ * broken bone is named as the cause rather than through its vertices.
1306
+ */
1307
+ export function nonFiniteOfPosed(where: string, skeleton: Skeleton): string | null {
1308
+ const bones: WorldTransform[] = skeleton.bones.map((bone) => {
1309
+ const { a, b, c, d, worldX, worldY } = bone.appliedPose;
1310
+ return { name: bone.data.name, a, b, c, d, worldX, worldY };
1311
+ });
1312
+ const found = firstNonFinite(where, [], bones);
1313
+ if (found !== null) return found;
1314
+ const entries: PosedVertices[] = [];
1315
+ for (const slot of skeleton.drawOrder.appliedPose) {
1316
+ const attachment = slot.appliedPose.attachment;
1317
+ if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
1318
+ entries.push({ slot: slot.data.name, attachment: attachment.name, vertices: worldVerticesOf(skeleton, slot, attachment) });
1319
+ }
1320
+ return firstNonFinite(where, entries, []);
1321
+ }
1322
+
1291
1323
  /**
1292
1324
  * How a sampled frame is named in that sentence: the animation, the frame's
1293
1325
  * index at the rate it was sampled and its time โ€” or the bare index for the one
package/src/rig.ts CHANGED
@@ -60,7 +60,7 @@
60
60
  * with no manifest at all declares them here.
61
61
  */
62
62
  import { CompileError, NotImplementedError } from './errors.ts';
63
- import { refuseUnknownKeys } from './keys.ts';
63
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys } from './keys.ts';
64
64
 
65
65
  export { CompileError, NotImplementedError };
66
66
 
@@ -1925,6 +1925,10 @@ function checkRigSequence(att: Record<string, unknown>, who: string, where: stri
1925
1925
  const whole = (field: string, min: number): void => {
1926
1926
  const value = seq[field];
1927
1927
  if (value === undefined) return;
1928
+ // A number the file cannot carry is `refuseNumbersTheFileCannotCarry`'s,
1929
+ // one sentence for the whole family. This check runs inside the key scan,
1930
+ // before that walk, and printed a stated 1e309 as `null` (issue #881).
1931
+ if (typeof value === 'number' && !Number.isFinite(Math.fround(value))) return;
1928
1932
  if (typeof value !== 'number' || !Number.isInteger(value) || value < min) {
1929
1933
  throw new CompileError(
1930
1934
  `${where}: ${at}.${field} is ${JSON.stringify(value) ?? String(value)}; it is a whole number` +
@@ -1946,7 +1950,7 @@ function checkRigSequence(att: Record<string, unknown>, who: string, where: stri
1946
1950
  whole('digits', 0);
1947
1951
  whole('setup', 0);
1948
1952
  const count = seq.count as number;
1949
- if (typeof seq.setup === 'number' && seq.setup >= count) {
1953
+ if (typeof seq.setup === 'number' && Number.isFinite(Math.fround(seq.setup)) && Number.isFinite(Math.fround(count)) && seq.setup >= count) {
1950
1954
  throw new CompileError(
1951
1955
  `${where}: ${at}.setup is ${seq.setup}, and a ${count}-frame series has frames 0 to ${count - 1}. ` +
1952
1956
  '`Sequence.resolveIndex` clamps an index at or past the end to the LAST frame (measured: `setup: 7` on ' +
@@ -2100,6 +2104,42 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): void {
2100
2104
  }
2101
2105
  }
2102
2106
 
2107
+
2108
+ /**
2109
+ * A rig-spec path in the words the other refusals in this file use for it:
2110
+ * `bone "hip" x`, `constraint "aim" mixRotate`, `skin "default" slot "tail"
2111
+ * attachment "tail" weights[0][1].x`, `event "step" float`. Anything else is
2112
+ * the dotted path, which names every number exactly.
2113
+ */
2114
+ function rigPlace(raw: Record<string, unknown>): (path: ReadonlyArray<string | number>) => string {
2115
+ const named = (list: unknown, i: number, kind: string, plural: string): string => {
2116
+ const node = Array.isArray(list) ? list[i] : undefined;
2117
+ return isObj(node) && typeof node.name === 'string' ? `${kind} ${JSON.stringify(node.name)}` : `${plural}[${i}]`;
2118
+ };
2119
+ const rest = (path: ReadonlyArray<string | number>): string => {
2120
+ const tail = dottedPath(path);
2121
+ return tail === '' ? '' : ` ${tail}`;
2122
+ };
2123
+ return (path) => {
2124
+ const [head, second] = path;
2125
+ if (typeof second === 'number') {
2126
+ if (head === 'bones') return `${named(raw.bones, second, 'bone', 'bones')}${rest(path.slice(2))}`;
2127
+ if (head === 'slots') return `${named(raw.slots, second, 'slot', 'slots')}${rest(path.slice(2))}`;
2128
+ if (head === 'constraints') return `${named(raw.constraints, second, 'constraint', 'constraints')}${rest(path.slice(2))}`;
2129
+ }
2130
+ if (head === 'events' && typeof second === 'string') return `event ${JSON.stringify(second)}${rest(path.slice(2))}`;
2131
+ if (head === 'skins' && typeof second === 'string') {
2132
+ const inner = path[2] === 'attachments' ? path.slice(3) : path.slice(2);
2133
+ const [slot, attachment] = inner;
2134
+ if (typeof slot === 'string' && typeof attachment === 'string') {
2135
+ return `skin ${JSON.stringify(second)} slot ${JSON.stringify(slot)} attachment ${JSON.stringify(attachment)}${rest(inner.slice(2))}`;
2136
+ }
2137
+ return `skin ${JSON.stringify(second)}${rest(path.slice(2))}`;
2138
+ }
2139
+ return dottedPath(path);
2140
+ };
2141
+ }
2142
+
2103
2143
  /**
2104
2144
  * Parse and check the envelope, then hand back a typed spec.
2105
2145
  *
@@ -2137,6 +2177,10 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2137
2177
  // throws directly below are the required-key checks, and each of them is the
2138
2178
  // consequence a typo at the root produces.
2139
2179
  checkRigSpecKeys(raw, where);
2180
+ // After the key check, so a misspelled key is named as a misspelling before
2181
+ // its value is judged, and before every reader below, so no range rule or
2182
+ // derived number ever meets a value the file cannot carry (issue #881).
2183
+ refuseNumbersTheFileCannotCarry(raw, where, rigPlace(raw));
2140
2184
 
2141
2185
  if (typeof raw.name !== 'string' || raw.name.length === 0) {
2142
2186
  throw new CompileError(`${where}: a rig spec needs a "name" โ€” a motion spec names it to pick this rig`);
package/src/validate.ts CHANGED
@@ -82,6 +82,7 @@ import {
82
82
  type SpineGeneration,
83
83
  } from './generation.ts';
84
84
  import { colourTypeName, readPngHeader } from './png.ts';
85
+ import { nonFiniteOfPosed } from './render.ts';
85
86
  import { BONE_INHERIT_KNOWN } from './rig.ts';
86
87
  import {
87
88
  CHANNELS_BY_KIND,
@@ -4400,6 +4401,26 @@ export function validate(input: ValidateInput): ValidateReport {
4400
4401
  }
4401
4402
  if (unresolved > 0) return;
4402
4403
 
4404
+ // -- the whole world transform, at the setup pose and every stepped frame
4405
+ //
4406
+ // ๐Ÿšจ Read through `nonFiniteOfPosed`, the scan `render` refuses on, and
4407
+ // not a second opinion of it (issue #882). This loop read `worldX` and
4408
+ // `worldY` alone until then, and a bone at `rotation: 1e309` โ€” in its setup
4409
+ // pose or as a `rotate` key โ€” poses a finite position over a NaN `a`, `b`,
4410
+ // `c` and `d` whenever it has no child offset from it: measured on every
4411
+ // leaf bone of the three generated probes, 20 of 20 green through the whole
4412
+ // gate, and `render` then refused the same file by the bone. So the gate
4413
+ // said the skeleton was fine and the renderer said it was not, which is two
4414
+ // definitions of one word. The scan reads the six terms of every bone, then
4415
+ // the vertices of every region and mesh shown โ€” because a bone can be
4416
+ // finite over a vertex that is not (a `scaleX` chain whose product stays
4417
+ // under the largest double while every corner of the child's region
4418
+ // passes it) โ€” and names the bone or the vertex, the term and the frame.
4419
+ //
4420
+ // โš ๏ธ The setup pose is its own surface and is read as such: every frame
4421
+ // below is posed AFTER `state.apply`, so a bone the animation keys from
4422
+ // t=0 never shows its setup value to the loop, and a runtime that shows
4423
+ // the rig at rest does show it.
4403
4424
  for (const anim of data.animations) {
4404
4425
  const skeleton = new Skeleton(data);
4405
4426
  const state = new AnimationState(new AnimationStateData(data));
@@ -4407,18 +4428,27 @@ export function validate(input: ValidateInput): ValidateReport {
4407
4428
  skeleton.setupPose();
4408
4429
  skeleton.update(0);
4409
4430
  skeleton.updateWorldTransform(Physics.reset);
4431
+ const atSetup = nonFiniteOfPosed('the setup pose', skeleton);
4432
+ if (atSetup !== null) {
4433
+ fail('A10_NO_NAN_AFTER_STEPPING', atSetup);
4434
+ return;
4435
+ }
4410
4436
  const step = Math.max(anim.duration, 1) / STEP_FRAMES;
4411
4437
  for (let i = 0; i < STEP_FRAMES; i++) {
4412
4438
  state.update(step);
4413
4439
  state.apply(skeleton);
4414
4440
  skeleton.update(step);
4415
4441
  skeleton.updateWorldTransform(Physics.update);
4442
+ const found = nonFiniteOfPosed(
4443
+ `animation ${JSON.stringify(anim.name)} frame ${i + 1} of ${STEP_FRAMES} (t=${((i + 1) * step).toFixed(4)}s)`,
4444
+ skeleton,
4445
+ );
4446
+ if (found !== null) {
4447
+ fail('A10_NO_NAN_AFTER_STEPPING', found);
4448
+ return;
4449
+ }
4416
4450
  for (const bone of skeleton.bones) {
4417
4451
  const pose = bone.appliedPose;
4418
- if (!Number.isFinite(pose.worldX) || !Number.isFinite(pose.worldY)) {
4419
- fail('A10_NO_NAN_AFTER_STEPPING', `${anim.name}: bone "${bone.data.name}" world is (${pose.worldX}, ${pose.worldY})`);
4420
- return;
4421
- }
4422
4452
  // The setup half of the clause above: a bone's own `inherit` goes
4423
4453
  // through the same lookup, and a miss there loads `undefined` into
4424
4454
  // the setup pose, which every frame copies. Only reachable on a