spine-rigc 1.5.2 → 1.5.3

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
@@ -1423,7 +1423,7 @@ it would simply be a second root.
1423
1423
  | `skin` | `BoneData.skinRequired` | `false` |
1424
1424
  | `color` | `rrggbbaa`, editor affordance | — |
1425
1425
  | `icon` | the editor's icon for this bone, e.g. `arrowsB`; editor affordance, no rendering effect. Copied through verbatim — no assertion checks the name, because the icon vocabulary is the editor's and an unknown one is not an error | — |
1426
- | `from` | **rigc extension** — take `x`/`y` (and optionally `rotation`) from a cut manifest | — |
1426
+ | `from` | **rigc extension** — take `x`/`y` from a cut manifest: `from.anchor`, `from.slotWindow` or `from.meshCenter`, exactly one. `from.rotation` is `"axis"` (the manifest's `axis.deg`, negated into y-up) or `"anchor"` (the third element of the named anchor, a screen-space facing angle); any other value is refused by name — it used to build as no rotation at all | — |
1427
1427
 
1428
1428
  ⚠️ Spine 4.0/4.1 called `inherit` **`transform`**. That old key still *loads* in 4.3
1429
1429
  and the inheritance silently falls back to Normal — assertion `A02` refuses it.
@@ -1803,7 +1803,7 @@ region, a trigger volume — that moves with the skeleton and draws nothing.
1803
1803
  | `vertexCount` | **required**, 3 or more. No default — the paragraph above is why |
1804
1804
  | `vertices` | the unweighted `x, y` run, in the slot bone's local space; or the index-encoded weighted run, behind `boneIndexing` |
1805
1805
  | `weights` | the by-name form: one entry per vertex, each a list of `{ "bone": …, "x": …, "y": …, "weight": … }`, each pair in that bone's local space. Never beside `vertices` |
1806
- | `boneIndexing` | `"name"` (the default) or `"raw"`, which opts a weighted `vertices` run into Spine's index encoding. `"raw"` beside `weights` is refused: `weights` always binds by name |
1806
+ | `boneIndexing` | `"name"` (the default) or `"raw"`, which opts a weighted `vertices` run into Spine's index encoding. `"raw"` beside `weights` is refused: `weights` always binds by name. Any other value — `"named"` included — is refused by name; it used to build as `"name"` |
1807
1807
  | `color` | `rrggbbaa`. **No default in the file** — omitted, the parser never calls `setFromString` and the attachment keeps the runtime's own colour. It is an editor affordance, the colour the polygon is drawn in there; rigc emits it verbatim when stated and leaves the key out when not |
1808
1808
 
1809
1809
  **Clipping polygon** ([Spine: clipping](http://esotericsoftware.com/spine-clipping)) —
@@ -1861,7 +1861,8 @@ is the wrap-around curve from `x = 180` back to `x = 0`, which nothing reads. Th
1861
1861
  `worldX = 45.000000`.
1862
1862
 
1863
1863
  The generators are `ring`, `ribbon`, `contour`, `grid` and `segments` (see
1864
- [`src/mesh.ts`](../src/mesh.ts)); the first two encode a deformation model rather
1864
+ [`src/mesh.ts`](../src/mesh.ts)), and a generator's `kind` is required: any other
1865
+ value, or none, is refused naming the five; the first two encode a deformation model rather
1865
1866
  than a table of numbers, which is why they are code invoked by data. `contour`
1866
1867
  and `grid` are geometry: they pin every vertex to the slot bone and exist to give
1867
1868
  a `deform` timeline (§4.12) somewhere to push. `segments` is both at once — a
@@ -5450,11 +5451,12 @@ out of it.)
5450
5451
 
5451
5452
  🔢 **A fourth surface is a pose that is not finite**, which `render` and `check`
5452
5453
  refuse before drawing anything, naming the bone or vertex and its value.
5453
- `A10_NO_NAN_AFTER_STEPPING` reads the same scan, so on a skeleton with an animation
5454
- `validate` gets there first and in the same words: a bone `rotation` of `1e309`, in
5455
- the setup pose or in a key, leaves the bone's world position finite and its matrix
5456
- `NaN`, and A10 names the bone and the term. What it does not see is a skeleton with
5457
- no animation (A10 SKIPs, so `render` is where that one is named) and a skin other
5454
+ `A10_NO_NAN_AFTER_STEPPING` reads the same scan, so `validate` gets there first and
5455
+ in the same words: a bone `rotation` of `1e309`, in the setup pose or in a key,
5456
+ leaves the bone's world position finite and its matrix `NaN`, and A10 names the bone
5457
+ and the term. That holds on a skeleton with no animation too — A10 reads its setup
5458
+ pose, which is the whole of what a static rig shows, and says on the stats line that
5459
+ it had nothing to step (`nanStepping=skipped`). What it does not see is a skin other
5458
5460
  than the default, which A10 does not pose. The sentence, and where to look for the
5459
5461
  number, is under *nothing to draw* in §3.4.
5460
5462
 
@@ -5581,7 +5583,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
5581
5583
  | `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 |
5582
5584
  | `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 |
5583
5585
  | `bone "X" names parent "Y", which is not declared before it` | move `Y` earlier in `bones` |
5584
- | `<file>: <where> is a string "5"; a number is required` (also `is a boolean true`, `is an array [1]`, `is null`, `is a number 7; a string is required`, `…; an array of numbers is required`, `…; a boolean (true or false) is required`, `…; a string or null is required`) | §3 / §4 — write the value as the JSON type its field holds: a number as a JSON number (`5`, not `"5"`), a flag as `true`/`false`, a name as a string. Every field of the three input files has one type, and a value of another is refused by the field before any reader does arithmetic on it — which it used to: `"x": "5"` built as `5`, `true` as `1` and `[1]` as `1`, and a `null` built too, all green. An array is typed element by element, so the refusal names the index (`weights[3][1].x`, `vertices[0]`). `null` is refused as `null` except where the field states it means something (`skeleton.width`/`height`, `skeleton.audio`, a slot's and a setup entry's `attachment`, a deform key's `vertices`, the manifest's `image`, `states`, `contact_depth` and `cap_containment_ceiling`). `<where>` is the rig spec's wording (`bone "hip" x`, `skin "default" slot "s" attachment "a" rotation`, `constraint "aim" mix`), the dotted path in the cut manifest (`crop.h`, `parts[2].offset[0]`), and the motion spec's own path (`` `animations."walk".ik[0].keys[0].mix` ``). In the rig spec and the manifest it runs right after the key scan and before every other check, so a field check that used to name a type (a mesh name that is not a string, a `lengths` entry spelt as a string) now reads as this row; in the motion spec it runs after that file's own field checks, which keep their sentences. A closed set of names (`blend`, `inherit`, a constraint's `type`, a generator's `kind`) keeps its own refusal, which lists the names that exist |
5586
+ | `<file>: <where> is a string "5"; a number is required` (also `is a boolean true`, `is an array [1]`, `is null`, `is a number 7; a string is required`, `…; an array of numbers is required`, `…; a boolean (true or false) is required`, `…; a string or null is required`) | §3 / §4 — write the value as the JSON type its field holds: a number as a JSON number (`5`, not `"5"`), a flag as `true`/`false`, a name as a string. Every field of the three input files has one type, and a value of another is refused by the field before any reader does arithmetic on it — which it used to: `"x": "5"` built as `5`, `true` as `1` and `[1]` as `1`, and a `null` built too, all green. An array is typed element by element, so the refusal names the index (`weights[3][1].x`, `vertices[0]`). `null` is refused as `null` except where the field states it means something (`skeleton.width`/`height`, `skeleton.audio`, a slot's and a setup entry's `attachment`, a deform key's `vertices`, the manifest's `image`, `states`, `contact_depth` and `cap_containment_ceiling`). `<where>` is the rig spec's wording (`bone "hip" x`, `skin "default" slot "s" attachment "a" rotation`, `constraint "aim" mix`), the dotted path in the cut manifest (`crop.h`, `parts[2].offset[0]`), and the motion spec's own path (`` `animations."walk".ik[0].keys[0].mix` ``). In the rig spec and the manifest it runs right after the key scan and before every other check, so a field check that used to name a type (a mesh name that is not a string, a `lengths` entry spelt as a string) now reads as this row; in the motion spec it runs after that file's own field checks, which keep their sentences. A closed set of names is not a type, and is the next row's |
5587
+ | `<file>: <where> is "foo"; one of "name", "raw" — anything else was read as …` (also `bone "b" from.rotation is 5; one of "axis", "anchor"`, the manifest's `parts[1].mesh.kind is "foo"; one of "ring", "ribbon"`, and `skin "s" slot "x" attachment "a" generator.kind is "foo"; one of "ring", "ribbon", "contour", "grid", "segments"`, or `… generator.kind is absent; …`) | §3.2 / §3.4 — write one of the names listed. A field that holds one of a closed set of names is refused, with the set, when it holds anything else — a number too, since a number is not a name. Most such fields have a reader that refuses by name with its own sentence (`blend`, `inherit`, a constraint's `type`, `scaleY`); these four had none, and each built green or crashed: `boneIndexing` read as `"name"`, `from.rotation` as no rotation, the manifest's `mesh.kind` as a ribbon by one reader and a ring by two others, and a generator `kind` threw a `TypeError`. The sentence after the dash says what the value used to be read as, which is the build you had before this refusal. The first three run right after the type row's walk, before every other check; the generator's `kind` is refused earlier, by the key scan, because it chooses which keys the rest of the generator may hold |
5585
5588
  | `<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 the row above refuses it as one |
5586
5589
  | `two bones are called "X"` | bone names are the join key; rename one |
5587
5590
  | `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 |
@@ -5808,7 +5811,7 @@ Fix A00 and run it again.
5808
5811
  | `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` |
5809
5812
  | `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 |
5810
5813
  | `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 |
5811
- | `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 |
5814
+ | `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. 🧍 **A skeleton with no animation is still read**: its setup pose is the whole of what it shows, so A10 reads it — the world transform and the vertices as above, and the bone's mode and the slot colours a stepped frame is read for, which on a static rig nothing else would reach — and names it `the setup pose` (`the setup pose: slot "x" colour is non-finite`). The stepping half had nothing, and a PASS row carries no detail, so the stats line says so: `nanStepping=skipped`, beside `animations=0`. `A09` does not follow — a declared duration is a fact about an animation and nothing else, so a static rig still gives it nothing. **SKIP** only when the skeleton carries **no bone and no animation**: there is then no pose to read and nothing to step |
5812
5815
  | `A11_NO_CLIPPING_ATTACHMENTS` | renderer | a clipping attachment; the target renderer skips them silently |
5813
5816
  | `A12_NO_DARK_COLOR` | renderer | a slot `dark` colour or an `rgba2`/`rgb2` timeline; parsed, then ignored |
5814
5817
  | `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 |
package/docs/MOTION.md CHANGED
@@ -982,9 +982,11 @@ are entirely the motion spec's business.
982
982
 
983
983
  ### 4. In-between it
984
984
 
985
- Duration 0.55 s, proposed (§3.3). Three easings (§3.4). The arm is the driver; the flag
986
- is the next link out and it is light, so §3.7's table puts its extreme **+20–35 %** after
987
- the arm's and gives it an overshoot. *"Snap"* buys an anticipation (§3.6) and an
985
+ Duration 0.55 s, proposed (§3.3). Three easings (§3.4). The arm is the driver. The flag
986
+ is the last thing on it and it is cloth, which is the row §3.7's table gives to *a
987
+ pennant* — not the *next link out* row, because nothing hangs off the flag — so its
988
+ extreme lands **+20–35 %** after the arm's, it overshoots, and it settles last with one
989
+ crossing. *"Snap"* buys an anticipation (§3.6) and an
988
990
  overshoot (§3.8). The post is planted: ⛔ **no timeline**.
989
991
 
990
992
  `semaphore.motion.json` — complete, nothing trimmed:
@@ -1020,8 +1022,8 @@ overshoot (§3.8). The post is planted: ⛔ **no timeline**.
1020
1022
  "keys": [
1021
1023
  { "t": 0, "v": [-22.2], "ease": "gather" },
1022
1024
  { "t": 0.09, "v": [-30.1], "ease": "charge" },
1023
- { "t": 0.38, "v": [-3.8], "ease": "settle" },
1024
- { "t": 0.48, "v": [-15.4], "ease": "settle" },
1025
+ { "t": 0.47, "v": [-3.8], "ease": "settle" },
1026
+ { "t": 0.51, "v": [-15.4], "ease": "settle" },
1025
1027
  { "t": 0.55, "v": [-11.7] }
1026
1028
  ]
1027
1029
  }
@@ -1040,8 +1042,8 @@ which is which is worth being able to point at:
1040
1042
  | arm `t: 0.55` = 18.3, flag `t: 0.55` = −11.7 | **given** — pose B. The flag's is `6.6 − 18.3`: both world rotations converted first, then differenced, because a child's track carries a **local** rotation under a rotated parent |
1041
1043
  | arm `t: 0.07` = −66.4 | §3.6 — 4.5° against an 80° excursion (5.6 %), at 13 % of the duration |
1042
1044
  | arm `t: 0.32` = 24.7 | §3.8 — 6.4° past the end value (8 %), at 58 % of the duration |
1043
- | flag `t: 0.09`, `t: 0.38` | §3.7 — the flag's extreme lands at 69 % against the arm's 58 %, an offset of **+11 %**, and it drags the other way first |
1044
- | flag `t: 0.48` = −15.4 | §3.7's *one crossing* for a loose part: it comes back past its own end value before settling |
1045
+ | flag `t: 0.09`, `t: 0.47` | §3.7 — the flag's extreme lands at 85 % of the duration against the arm's 58 %, an offset of **+27 %**, the middle of its row's band, and it drags the other way first. Not the top of the band: at 35 % (`t: 0.51`) the crossing and the settle below would share 0.04 s, and the 24 fps render would still show the flag at its extreme one frame before the last |
1046
+ | flag `t: 0.51` = −15.4 | §3.7's *one crossing* for a loose part: it comes back past its own end value before settling, keyed halfway between its extreme and the end |
1045
1047
  | the three easings | §3.4 — one that gathers, one that arrives slowly, one symmetric. The **last key of each track carries no easing**, because there is nothing after it to ease towards (AUTHORING §4.5) |
1046
1048
  | the post's absent track | §3.7 — a planted part gets no timeline |
1047
1049
 
@@ -1071,7 +1073,9 @@ Open `render/raise@24fps/contact.png` **before anything else** — fourteen fram
1071
1073
  grid, and spacing is a comparison across frames rather than a property of any one of
1072
1074
  them. What to check on it, and it is not a score: frame 0 is pose A — the flag hanging off
1073
1075
  the arm's collar, not over its middle — the last frame is pose B, the anticipation dips
1074
- *after* frame 0, and the flag's extreme is visibly later than the arm's.
1076
+ *after* frame 0, and the flag's extreme is visibly later than the arm's: the arm peaks at
1077
+ frame 8 while the flag is still closing on the arm's line, it keeps closing through
1078
+ frame 11 as the arm eases back, and at frame 12 it drops past its resting angle.
1075
1079
 
1076
1080
  🚫 **Do not run `rigc check` against `poseA.png` and `poseB.png`.** Two pictures are not
1077
1081
  a frame set: `check` reads the second as the frame 1/12 s in, at its default rate. And
@@ -1115,21 +1119,21 @@ rigc vote --candidate spine --candidate spine-b
1115
1119
 
1116
1120
  ```
1117
1121
  rigc vote
1118
- .. ballot bce70ad82fe90941
1122
+ .. ballot 862f444c58d10e3e
1119
1123
  .. animation raise
1120
- .. A sha256:6740d3dbf304… 3 page(s), 0.4 KiB <- …/semaphore/spine/skeleton.json
1124
+ .. A sha256:c49e458df44e… 3 page(s), 0.4 KiB <- …/semaphore/spine/skeleton.json
1121
1125
  .. B sha256:d1bda24d7c36… 3 page(s), 0.4 KiB <- …/semaphore/spine-b/skeleton.json
1122
1126
  .. the page shows A/B and nothing else — the paths above are in its manifest, never on the screen
1123
1127
  .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from unpkg (@4.3.*), so the first open needs a network
1124
1128
  rigc: wrote …/semaphore/ballot.html (22.3 KiB — open it in a browser)
1125
- rigc: then record the saved vote with rigc vote --record vote-bce70ad82fe90941.json --ballot …/semaphore/ballot.html
1129
+ rigc: then record the saved vote with rigc vote --record vote-862f444c58d10e3e.json --ballot …/semaphore/ballot.html
1126
1130
  ```
1127
1131
 
1128
1132
  A person opens that page, watches two loops, picks one, and saves the small JSON it hands
1129
1133
  them. Then:
1130
1134
 
1131
1135
  ```bash
1132
- rigc vote --record vote-bce70ad82fe90941.json --ballot ballot.html
1136
+ rigc vote --record vote-862f444c58d10e3e.json --ballot ballot.html
1133
1137
  ```
1134
1138
 
1135
1139
  ```
@@ -1140,8 +1144,8 @@ rigc vote --record vote-bce70ad82fe90941.json --ballot ballot.html
1140
1144
  PASS V04_CHOICE_IS_ON_THE_BALLOT
1141
1145
  PASS V05_REASON_CODE_FITS_THE_CHOICE
1142
1146
  PASS V06_NOT_ALREADY_RECORDED
1143
- .. winner A = sha256:6740d3dbf3043ff39c5f90577407c381f6841ff6af921517be7a0cfd3d7eb9d3, reason code preferred
1144
- .. coverage 2 candidate(s): A=sha256:6740d3dbf304… B=sha256:d1bda24d7c36…
1147
+ .. winner A = sha256:c49e458df44efd277aa6e50ff8fa1962c1211c7617079d63fbc4cf8008eaf0c5, reason code preferred
1148
+ .. coverage 2 candidate(s): A=sha256:c49e458df44e… B=sha256:d1bda24d7c36…
1145
1149
  rigc: appended line 1 to …/semaphore/votes.jsonl
1146
1150
  ```
1147
1151
 
@@ -1169,9 +1173,13 @@ that the ends are stateable by construction (`pose`), that the file is checkable
1169
1173
  (`vote`).
1170
1174
 
1171
1175
  🚫 **No scoring of end-pose reach, and nothing here to add one to.** At L1 and L2 the
1172
- end poses are given conditions; a number saying how near the animation got to them is a
1173
- number about the pose estimator. This is why §6 does not run `check` on the two pictures
1174
- and why no threshold, tolerance or pass bar appears anywhere in this document. The
1176
+ end poses are given conditions, and what a picture gives is a **pose**: the rotations are
1177
+ `pose`'s readings, which the spec states by construction, so a number for how near the
1178
+ animation got to *them* is a number about the pose estimator. The **offsets** are the
1179
+ other half — derived from the pictures rather than given — and a rig can miss its
1180
+ picture by a wrong one with every key exact. Catching that is a look, not a score (§6.5's
1181
+ frame-0 check). This is why §6 does not run `check` on the two pictures and why no
1182
+ threshold, tolerance or pass bar appears anywhere in this document. The
1175
1183
  residuals in a `pose` report are trust signals about *placements*, and AUTHORING §11.1
1176
1184
  says the same from the instrument's side.
1177
1185
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.5.2",
3
+ "version": "1.5.3",
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,9 +38,9 @@ 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 { dottedPath, nearMisses, refuseNumbersTheFileCannotCarry, refuseValuesOfTheWrongType } from './keys.ts';
41
+ import { dottedPath, nearMisses, refuseNumbersTheFileCannotCarry, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
42
42
  import type { ShapeVisit } from './keys.ts';
43
- import { MANIFEST_TYPES } from './types.ts';
43
+ import { MANIFEST_ENUMS, MANIFEST_TYPES } from './types.ts';
44
44
  import { inEditorKeyOrder, withoutParserDefaults } from './keyorder.ts';
45
45
  import { EVERY_GLOBAL_PHYSICS, parseMotionSpec } from './motion.ts';
46
46
  import {
@@ -2062,7 +2062,13 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2062
2062
  // same rule over the other file whose numbers reach the skeleton.
2063
2063
  // Its values of the wrong type first (issue #890): `crop.h: "806"` built
2064
2064
  // green, the string coerced by whatever arithmetic read it.
2065
- if (manifest !== null && manifestPath !== null) refuseValuesOfTheWrongType(manifestShapeVisits(manifest), MANIFEST_TYPES, manifestPath);
2065
+ if (manifest !== null && manifestPath !== null) {
2066
+ const visits = manifestShapeVisits(manifest);
2067
+ refuseValuesOfTheWrongType(visits, MANIFEST_TYPES, manifestPath);
2068
+ // Then a name outside a stated set (issue #900): `mesh.kind: "foo"` was
2069
+ // read as a ribbon by one reader and a ring by two others.
2070
+ refuseValuesOutsideTheirSet(visits, MANIFEST_TYPES, MANIFEST_ENUMS, manifestPath);
2071
+ }
2066
2072
  if (manifest !== null && manifestPath !== null) refuseNumbersTheFileCannotCarry(manifest, manifestPath, dottedPath);
2067
2073
 
2068
2074
  // The rig spec names its own path in every message `parseRigSpec` throws (its
package/src/keys.ts CHANGED
@@ -219,8 +219,12 @@ export function dottedPath(path: ReadonlyArray<string | number>): string {
219
219
  * kind of container is the refusal of the reader that walks it (a rig spec's
220
220
  * `"bones"` that is not an array is *a rig spec needs a non-empty "bones"
221
221
  * array*);
222
- * - `enum` — a closed set of names, refused by name where it is read, with the
223
- * names that exist listed (a slot's `blend`, a constraint's `type`);
222
+ * - `enum` — a closed set of names. Every `enum` row has an entry in the
223
+ * shape's enum table (`RIG_ENUMS`, `MOTION_ENUMS`, `MANIFEST_ENUMS`, typed by
224
+ * `SpecEnumTable`), which either states the set — and
225
+ * `refuseValuesOutsideTheirSet` refuses a value outside it — or names the
226
+ * reader that refuses it where it reads it, with the names that exist listed
227
+ * (a slot's `blend`, a constraint's `type`);
224
228
  * - `mixed` — a union of kinds whose owner decides by the value (`MotionKey.v`
225
229
  * is numbers on `rotate`, a name on `attachment`, a member map on a group).
226
230
  */
@@ -410,3 +414,73 @@ export function refuseValuesOfTheWrongType(
410
414
  }
411
415
  }
412
416
  }
417
+
418
+ /**
419
+ * Who refuses a value outside an `enum` row's closed set (issue #900).
420
+ *
421
+ * - `set` — the names the row accepts, refused from one place by
422
+ * `refuseValuesOutsideTheirSet`; `readAs` is what the readers did with any
423
+ * other value before the set was stated, which the refusal says, because
424
+ * "one of" alone does not tell an author that the build they had was wrong;
425
+ * - `owner` — the function that reads the value and refuses one outside its
426
+ * set by name there, listing the names that exist. It keeps its own sentence,
427
+ * which often says more than a set can (a first letter whose case is free, a
428
+ * row chosen by the value itself).
429
+ */
430
+ export type SpecEnumRule = { readonly set: readonly string[]; readonly readAs: string } | { readonly owner: string };
431
+
432
+ /** The keys of a type row whose type is `enum`. */
433
+ type EnumKeysOf<R> = { [K in keyof R]: R[K] extends 'enum' ? K : never }[keyof R];
434
+
435
+ /**
436
+ * A type table's enum table: one entry per `enum` row, and nothing else.
437
+ *
438
+ * 🔒 `satisfies SpecEnumTable<typeof X_TYPES>` is what makes a fourth unowned
439
+ * enum a type error rather than a row nobody holds: a shape with an `enum` row
440
+ * must appear, with exactly its `enum` keys, and a key of another type cannot.
441
+ * A selftest control holds the same claim at runtime and reads each `owner`'s
442
+ * body for the key it is said to own.
443
+ */
444
+ export type SpecEnumTable<T extends Readonly<Record<string, SpecTypeRow>>> = {
445
+ readonly [S in keyof T as [EnumKeysOf<T[S]>] extends [never] ? never : S]: { readonly [K in EnumKeysOf<T[S]>]: SpecEnumRule };
446
+ };
447
+
448
+ /**
449
+ * Refuse the first value of an `enum` row whose enum table states a set and
450
+ * that is not in it (issue #900).
451
+ *
452
+ * 🚨 The defect this closes was a green build again, one step past #890's:
453
+ * the type walk does not check `enum` (a name's type says nothing about which
454
+ * names exist), and three rows had nobody holding the name — `boneIndexing`,
455
+ * a bone's `from.rotation` and the cut manifest's `mesh.kind`. `"foo"` and `5`
456
+ * were each read as something the spec never said, in silence.
457
+ *
458
+ * It runs after the type walk and over the same visits, so a key the scan
459
+ * refused is never judged here, and before every reader. A row whose table
460
+ * entry names an `owner` is passed over: that reader refuses it with its own
461
+ * sentence. The order is the type walk's — visits in the scan's order, keys in
462
+ * the document's — so the refusal names the same value on every run.
463
+ */
464
+ export function refuseValuesOutsideTheirSet(
465
+ visits: readonly ShapeVisit[],
466
+ types: Readonly<Record<string, SpecTypeRow>>,
467
+ enums: Readonly<Record<string, Readonly<Record<string, SpecEnumRule>>>>,
468
+ where: string,
469
+ ): void {
470
+ for (const visit of visits) {
471
+ const row = types[visit.shape];
472
+ if (row === undefined) throw new Error(`no type row for shape ${visit.shape} (a key scan visited a shape the type table does not have)`);
473
+ for (const key of Object.keys(visit.node)) {
474
+ if (row[key] !== 'enum') continue;
475
+ const rule = enums[visit.shape]?.[key];
476
+ if (rule === undefined) throw new Error(`${visit.shape}.${key} is an enum row with no entry in its enum table`);
477
+ if (!('set' in rule)) continue;
478
+ const value = visit.node[key];
479
+ if (typeof value === 'string' && rule.set.includes(value)) continue;
480
+ throw new CompileError(
481
+ `${where}: ${visit.name([key])} is ${JSON.stringify(value) ?? String(value)}; one of ` +
482
+ `${rule.set.map((name) => JSON.stringify(name)).join(', ')} — ${rule.readAs}`,
483
+ );
484
+ }
485
+ }
486
+ }
package/src/motion.ts CHANGED
@@ -54,8 +54,8 @@
54
54
  * [`keys.ts`](keys.ts) shared with the rig parser.
55
55
  */
56
56
  import { CompileError } from './errors.ts';
57
- import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType } from './keys.ts';
58
- import type { ShapeVisit, SpecValueType } from './keys.ts';
57
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
58
+ import type { ShapeVisit, SpecEnumTable, SpecValueType } from './keys.ts';
59
59
  import { SEQUENCE_MODES } from './timelines.ts';
60
60
  import type { MotionSpec } from './types.ts';
61
61
 
@@ -165,9 +165,8 @@ export const MOTION_KEYS = {
165
165
  * fields nothing here reads — an ik key's `mix`, a deform key's `offset`, an
166
166
  * event key's `float`, a derive's `degrees`.
167
167
  *
168
- * Where an unchecked type is refused: `spec` — the version check; a track's
169
- * `property` — `compileTrack`; a derive's `kind` — `evaluateTrackDerive`; a deform
170
- * transform's `kind`, `along` and `axis` — `evaluateDeformTransform`;
168
+ * Where an unchecked type is refused: an `enum` row — the entry `MOTION_ENUMS`
169
+ * has for it, below, which is a table a control reads rather than a list here;
171
170
  * `MotionKey.v`, a key's `curve`, a derive's `depth` and `mix.pairs` (`mixed`)
172
171
  * — the track compiler by property, the curve reader, the derive evaluator and
173
172
  * `parseMix`. A sequence key's `mode` is a closed set too, but its interface
@@ -227,6 +226,24 @@ export const MOTION_TYPES = {
227
226
  DeformBend: { kind: 'enum', amount: 'number', from: 'number', to: 'number', power: 'number', along: 'enum', axis: 'enum' },
228
227
  } as const satisfies MotionTypeTable;
229
228
 
229
+ /**
230
+ * Who refuses each `enum` row of `MOTION_TYPES` outside its set (issue #900);
231
+ * `SpecEnumTable` in [`keys.ts`](keys.ts) says what an entry means. Every one
232
+ * is an owner: measured when this table was written, a planted `5` and `"foo"`
233
+ * on each row was refused by name by the reader named here, listing the names
234
+ * that exist, so none of them states a set. A deform transform's and a derive's
235
+ * `kind` choose the row they are checked against, as a generator's does.
236
+ */
237
+ export const MOTION_ENUMS = {
238
+ MotionSpec: { spec: { owner: 'parseMotionSpecInto' } },
239
+ MotionTrack: { property: { owner: 'compileTrack' } },
240
+ TrackDeriveTurn: { kind: { owner: 'evaluateTrackDerive' } },
241
+ DeformTurn: { kind: { owner: 'evaluateDeformTransform' } },
242
+ DeformAffine: { kind: { owner: 'evaluateDeformTransform' } },
243
+ DeformWave: { kind: { owner: 'evaluateDeformTransform' }, along: { owner: 'evaluateDeformTransform' }, axis: { owner: 'evaluateDeformTransform' } },
244
+ DeformBend: { kind: { owner: 'evaluateDeformTransform' }, along: { owner: 'evaluateDeformTransform' }, axis: { owner: 'evaluateDeformTransform' } },
245
+ } as const satisfies SpecEnumTable<typeof MOTION_TYPES>;
246
+
230
247
  /** A deform key's `transform` kinds, and the shape each one's keys come from. */
231
248
  const DEFORM_TRANSFORM_SHAPE: Record<string, keyof typeof MOTION_KEYS> = {
232
249
  yaw: 'DeformTurn',
@@ -778,6 +795,9 @@ function parseMotionSpecInto(raw: unknown, where: string, visits: readonly Shape
778
795
  // as an event with no audio, a yaw transform's `"depth": "true"` as a radius
779
796
  // that is undefined.
780
797
  refuseValuesOfTheWrongType(visits, MOTION_TYPES, where);
798
+ // Every `enum` row here names an owner, so this refuses nothing today; it is
799
+ // called so that a row given a set is refused from the day it is given one.
800
+ refuseValuesOutsideTheirSet(visits, MOTION_TYPES, MOTION_ENUMS, where);
781
801
 
782
802
  // Last, so every field check above keeps its own sentence for a number that
783
803
  // is not finite; what reaches this line is a finite double the float32 file
package/src/rig.ts CHANGED
@@ -60,8 +60,8 @@
60
60
  * with no manifest at all declares them here.
61
61
  */
62
62
  import { CompileError, NotImplementedError } from './errors.ts';
63
- import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType } from './keys.ts';
64
- import type { ShapeVisit, SpecValueType } from './keys.ts';
63
+ import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
64
+ import type { ShapeVisit, SpecEnumTable, SpecValueType } from './keys.ts';
65
65
 
66
66
  export { CompileError, NotImplementedError };
67
67
 
@@ -1866,29 +1866,20 @@ export const RIG_KEYS = {
1866
1866
  * field declared `number` and typed `string` here is a red run rather than a
1867
1867
  * refusal of correct work.
1868
1868
  *
1869
- * Where an unchecked type (`object`, `enum`, `mixed` — see `SPEC_VALUE_TYPES`)
1870
- * is refused, and by what:
1871
- *
1872
- * - the version tag `spec` — `parseRigSpec`, before anything else;
1873
- * - `inherit` — `resolveBoneInherit`; `blend` — the slot loop
1874
- * (`RIG_SLOT_BLEND`); `consumerDrivenMix[].type` — the invariants block;
1875
- * - a constraint's `type` — `buildRigConstraint`; `scaleY` on ik, a path
1876
- * constraint's three modes and a slider's `property` — the builder of that
1877
- * constraint kind, each listing the names that exist;
1878
- * - an attachment's `type` — the attachment reader; `depth.near` — the depth
1879
- * map reader;
1880
- * - `RigSegmentsGenerator.bones` (`mixed`: a name, a chain of names or a span)
1881
- * — the segments generator, by index.
1882
- *
1883
- * 🚨 Measured when this table was written, three `enum` keys have **no** owner,
1884
- * and the table does not pretend otherwise: `boneIndexing` built green at `5`
1885
- * and at `"foo"` (on a weighted mesh too), `from.rotation` built green at `5`
1886
- * and at `"foo"` on an anchored bone, and a generator `kind` of `5` or `"foo"`
1887
- * threw a `TypeError` (*undefined is not an object (evaluating
1888
- * 'generator.size')*) rather than the mesh builder's refusal. Each is a closed
1889
- * set of names with nobody holding the name, which is a value refusal and not a
1890
- * type one, so it is not fixed here. `scaleY` on a physics constraint was not
1891
- * measured.
1869
+ * Where an unchecked `object` or `mixed` row is refused: a nested shape by the
1870
+ * reader that walks it; `RigSegmentsGenerator.bones` (`mixed`: a name, a chain
1871
+ * of names or a span) by the segments generator, by index. Where an `enum` row
1872
+ * is refused is not a list in prose any more: `RIG_ENUMS`, below, has one entry
1873
+ * per `enum` row — a set refused by `refuseValuesOutsideTheirSet`, or the
1874
+ * reader that refuses it — and `satisfies` makes a row without one a type error.
1875
+ *
1876
+ * ⚠️ Until issue #900 this comment carried that list, and ended by naming three
1877
+ * `enum` keys with **no** owner: `boneIndexing`, `from.rotation` and a
1878
+ * generator `kind`. The list was right about the three and wrong by omission
1879
+ * about a fourth — the cut manifest's `mesh.kind`, which `MANIFEST_TYPES` said
1880
+ * "the manifest mesh reader" held and which that reader read as a ribbon in one
1881
+ * place and a ring in another. A list of owners in a comment is a claim nothing
1882
+ * checks; the table is one a control reads.
1892
1883
  */
1893
1884
  type RigTypeTable = {
1894
1885
  readonly [S in keyof typeof RIG_KEYS]: { readonly [K in (typeof RIG_KEYS)[S][number]]: SpecValueType };
@@ -1998,6 +1989,70 @@ export const RIG_TYPES = {
1998
1989
  RigSoftRegion: { bone: 'string', mask: 'string' },
1999
1990
  } as const satisfies RigTypeTable;
2000
1991
 
1992
+ /** The two sources a bone's `from.rotation` names (`RigBoneFrom.rotation`). */
1993
+ export const RIG_FROM_ROTATIONS = ['axis', 'anchor'] as const satisfies ReadonlyArray<NonNullable<RigBoneFrom['rotation']>>;
1994
+
1995
+ /** The two ways a weighted `vertices` run names its bones (`RigMeshAttachment.boneIndexing`). */
1996
+ export const RIG_BONE_INDEXING = ['name', 'raw'] as const satisfies ReadonlyArray<NonNullable<RigMeshAttachment['boneIndexing']>>;
1997
+
1998
+ /** What `boneIndexing` outside its set was read as, measured before issue #900. */
1999
+ const BONE_INDEXING_READ_AS =
2000
+ 'anything else was read as the default "name" in silence: a weighted "vertices" run was refused as unflagged, ' +
2001
+ 'and every other attachment built byte for byte as if the key were absent';
2002
+
2003
+ /**
2004
+ * Who refuses each `enum` row of `RIG_TYPES` outside its set (issue #900) —
2005
+ * `SpecEnumTable` in [`keys.ts`](keys.ts) says what the two kinds of entry
2006
+ * mean, and `satisfies` makes an `enum` row without one a type error.
2007
+ *
2008
+ * ⭐ A `set` is stated for exactly the rows nobody held: measured when this
2009
+ * table was written, `boneIndexing` built green at `5`, `"foo"` and `"named"`
2010
+ * (the spelling the issue itself used) as the default, and `from.rotation`
2011
+ * built green at `5` and `"foo"` with the bone's setup rotation dropped. Every
2012
+ * other row already had a reader that refused a planted `5` and `"foo"` by
2013
+ * name, and keeps it; the generator's `kind` is the one of those that is a
2014
+ * dispatch, and its refusal is new — it threw a `TypeError` (*undefined is not
2015
+ * an object (evaluating 'generator.size')*), because the scan skipped a `kind`
2016
+ * it had no row for and the mesh builder fell through to the ring branch.
2017
+ */
2018
+ export const RIG_ENUMS = {
2019
+ RigSpec: { spec: { owner: 'parseRigSpec' } },
2020
+ RigBone: { inherit: { owner: 'parseRigSpec' } },
2021
+ RigBoneFrom: {
2022
+ rotation: {
2023
+ set: RIG_FROM_ROTATIONS,
2024
+ readAs: 'anything else was read as no rotation source, and the bone was emitted without the setup rotation it asked for',
2025
+ },
2026
+ },
2027
+ RigSlot: { blend: { owner: 'parseRigSpec' } },
2028
+ RigConsumerDrivenMix: { type: { owner: 'parseRigSpec' } },
2029
+ RigIkConstraint: { type: { owner: 'buildRigConstraint' }, scaleY: { owner: 'buildRigConstraint' } },
2030
+ RigTransformConstraint: { type: { owner: 'buildRigConstraint' } },
2031
+ RigPathConstraint: {
2032
+ type: { owner: 'buildRigConstraint' },
2033
+ positionMode: { owner: 'buildRigConstraint' },
2034
+ spacingMode: { owner: 'buildRigConstraint' },
2035
+ rotateMode: { owner: 'buildRigConstraint' },
2036
+ },
2037
+ RigPhysicsConstraint: { type: { owner: 'buildRigConstraint' }, scaleY: { owner: 'buildRigConstraint' } },
2038
+ RigSliderConstraint: { type: { owner: 'buildRigConstraint' }, property: { owner: 'buildRigConstraint' } },
2039
+ RigRegionAttachment: { type: { owner: 'buildRigAttachment' } },
2040
+ RigMeshAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2041
+ RigLinkedMeshAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2042
+ RigBoundingBoxAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2043
+ RigClippingAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2044
+ RigPathAttachment: { type: { owner: 'buildRigAttachment' }, boneIndexing: { set: RIG_BONE_INDEXING, readAs: BONE_INDEXING_READ_AS } },
2045
+ // The generator's `kind` chooses the row its other keys are checked against,
2046
+ // so a `kind` outside the five has no row to be visited in: the scan that
2047
+ // dispatches on it is where it is refused.
2048
+ RigRingGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2049
+ RigRibbonGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2050
+ RigContourGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2051
+ RigGridGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2052
+ RigSegmentsGenerator: { kind: { owner: 'checkRigSpecKeys' } },
2053
+ RigDepthMap: { near: { owner: 'sampleMeshDepth' } },
2054
+ } as const satisfies SpecEnumTable<typeof RIG_TYPES>;
2055
+
2001
2056
  /**
2002
2057
  * The five constraint `type` names, and the shape each one's keys come from.
2003
2058
  *
@@ -2024,7 +2079,7 @@ const ATTACHMENT_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
2024
2079
  };
2025
2080
 
2026
2081
  /** The generator `kind` names, and the shape each one's keys come from. */
2027
- const GENERATOR_SHAPE: Record<string, keyof typeof RIG_KEYS> = {
2082
+ const GENERATOR_SHAPE: Record<(typeof RIG_GENERATOR_KINDS)[number], keyof typeof RIG_KEYS> = {
2028
2083
  ring: 'RigRingGenerator',
2029
2084
  ribbon: 'RigRibbonGenerator',
2030
2085
  contour: 'RigContourGenerator',
@@ -2239,10 +2294,21 @@ function checkRigSpecKeys(raw: Record<string, unknown>, where: string): Array<{
2239
2294
  }
2240
2295
  if (!isObj(att.generator)) continue;
2241
2296
  const gen = att.generator;
2242
- const genShape = GENERATOR_SHAPE[String(gen.kind)];
2243
- // Same rule as an unknown attachment type: an unknown `kind` is the mesh
2244
- // builder's refusal, which can name the four that exist.
2245
- if (genShape === undefined) continue;
2297
+ const kind = RIG_GENERATOR_KINDS.find((k) => k === gen.kind);
2298
+ const genShape = kind === undefined ? undefined : GENERATOR_SHAPE[kind];
2299
+ // 🚨 An unknown `kind` used to be skipped here as "the mesh builder's
2300
+ // refusal", and the mesh builder had none: `buildGeneratedMesh` fell
2301
+ // through to the ring branch and threw a TypeError reading
2302
+ // `generator.size` (issue #900). `kind` chooses the row every other key
2303
+ // of the generator is checked against, so this dispatch is the one
2304
+ // place that can refuse it — before any of those keys is judged.
2305
+ if (genShape === undefined) {
2306
+ const stated = gen.kind === undefined ? 'absent' : (JSON.stringify(gen.kind) ?? String(gen.kind));
2307
+ throw new CompileError(
2308
+ `${where}: ${who} generator.kind is ${stated}; one of ${RIG_GENERATOR_KINDS.map((k) => JSON.stringify(k)).join(', ')} — ` +
2309
+ 'the kind names the builder, and each builder reads its own keys, so nothing about the mesh can be checked or built without one',
2310
+ );
2311
+ }
2246
2312
  at(gen, genShape, `${who} generator (${String(gen.kind)})`, [...here, 'generator']);
2247
2313
  at(gen.bias, 'RigMeshBias', `${who} generator.bias`, [...here, 'generator', 'bias']);
2248
2314
  at(gen.depth, 'RigDepthMap', `${who} generator.depth`, [...here, 'generator', 'depth']);
@@ -2338,11 +2404,13 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2338
2404
  // `constraint.skin === true` reads `"skin": "true"` as a constraint that asks
2339
2405
  // for no skin. The price, measured, is three controls whose type half pinned an
2340
2406
  // older sentence (RF86, PS177, PS189); their other halves are unchanged.
2341
- refuseValuesOfTheWrongType(
2342
- visits.map((v) => ({ node: v.node, shape: v.shape, name: (tail) => place([...v.path, ...tail]) })),
2343
- RIG_TYPES,
2344
- where,
2345
- );
2407
+ const shapeVisits: ShapeVisit[] = visits.map((v) => ({ node: v.node, shape: v.shape, name: (tail) => place([...v.path, ...tail]) }));
2408
+ refuseValuesOfTheWrongType(shapeVisits, RIG_TYPES, where);
2409
+ // A name outside an `enum` row's stated set (issue #900), after the type walk
2410
+ // and over the same visits: `boneIndexing: "foo"` built as the default and
2411
+ // `from.rotation: "foo"` as no rotation, both green. A row whose entry names
2412
+ // an owner is that reader's to refuse, in its own words.
2413
+ refuseValuesOutsideTheirSet(shapeVisits, RIG_TYPES, RIG_ENUMS, where);
2346
2414
  // After the key check, so a misspelled key is named as a misspelling before
2347
2415
  // its value is judged, and before every reader below, so no range rule or
2348
2416
  // derived number ever meets a value the file cannot carry (issue #881).
package/src/types.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  import type { AtlasRegion } from './atlas.ts';
18
18
  import type { DeformTransform, DeformTransformReport } from './deformgen.ts';
19
19
  import type { TurnCeiling } from './depth.ts';
20
- import type { SpecTypeRow } from './keys.ts';
20
+ import type { SpecEnumTable, SpecTypeRow } from './keys.ts';
21
21
  import type { MeshKind } from './mesh.ts';
22
22
  import type { TrackDerive, TrackDeriveReport } from './trackgen.ts';
23
23
 
@@ -185,9 +185,9 @@ export interface FaceManifest {
185
185
  * wrong-typed plants on the three fixture manifests built green —
186
186
  * `crop.h: "256"` among them — and 4 threw a TypeError from `node:path`.
187
187
  *
188
- * Unchecked: `mesh.kind` and `mesh.hull` (`enum`) — the manifest mesh reader;
189
- * `crop` and the other nested shapes, `parts` and `mesh` (`object`) — the
190
- * readers that walk them.
188
+ * Unchecked by this walk: `mesh.kind` and `mesh.hull` (`enum`) — see
189
+ * `MANIFEST_ENUMS`, below; `crop` and the other nested shapes, `parts` and
190
+ * `mesh` (`object`) — the readers that walk them.
191
191
  */
192
192
  export const MANIFEST_TYPES = {
193
193
  FaceManifest: {
@@ -211,6 +211,34 @@ export const MANIFEST_TYPES = {
211
211
  'FaceManifestMesh.bias': { axis_deg: 'number', ramp: 'number[]', note: 'string' },
212
212
  } as const satisfies Record<string, SpecTypeRow>;
213
213
 
214
+ /** The two mesh generators a manifest part may name (`FaceManifestMesh.kind`); absent means `ring`. */
215
+ export const MANIFEST_MESH_KINDS = ['ring', 'ribbon'] as const satisfies ReadonlyArray<NonNullable<FaceManifestMesh['kind']>>;
216
+
217
+ /**
218
+ * Who refuses each `enum` row of `MANIFEST_TYPES` outside its set (issue #900);
219
+ * `SpecEnumTable` in [`keys.ts`](keys.ts) says what an entry means.
220
+ *
221
+ * 🚨 `mesh.kind` was listed as held by "the manifest mesh reader", and it was
222
+ * not held at all: that reader has three places that ask the kind, and they
223
+ * disagreed about a value outside the two. The mesh checks read `"foo"` as a
224
+ * ribbon, while the control-bone reader and the geometry builder read it as a
225
+ * ring — so a ring part was refused as *a ribbon mesh needs mesh.rows and a
226
+ * mesh.chain*, and a ribbon part as *a mesh with no control bone deforms
227
+ * nothing*, both naming a fault the part did not have. `hull` is held: the ring
228
+ * check refuses anything but `"polygon"` by name, and keeps that sentence.
229
+ */
230
+ export const MANIFEST_ENUMS = {
231
+ FaceManifestMesh: {
232
+ kind: {
233
+ set: MANIFEST_MESH_KINDS,
234
+ readAs:
235
+ 'anything else was read as a ribbon by the mesh checks and as a ring by the control-bone reader and the ' +
236
+ 'geometry builder, so the refusal it got named a fault the part did not have',
237
+ },
238
+ hull: { owner: 'compileInto' },
239
+ },
240
+ } as const satisfies SpecEnumTable<typeof MANIFEST_TYPES>;
241
+
214
242
  // ---------------------------------------------------------------------------
215
243
  // Motion spec (spec: "rigc-motion/1")
216
244
  // ---------------------------------------------------------------------------
package/src/validate.ts CHANGED
@@ -289,16 +289,17 @@ export const SKIP_NO_ATLAS = 'the round trip did not produce an atlas to measure
289
289
  * `A15_IDLE_NO_MESH_BONE_KEYS` is the pattern and already reads this way: no
290
290
  * `idle` animation, or an `idle` with no bone timeline, is the named subject
291
291
  * absent and SKIPs; no mesh-driving bone among the bones `idle` does key is
292
- * the construct absent and passes. `A10_NO_NAN_AFTER_STEPPING` is the same
293
- * shape — the stepping is per animation, so a skeleton with none has not
294
- * been stepped.
292
+ * the construct absent and passes. `A10_NO_NAN_AFTER_STEPPING` was the same
293
+ * shape until issue #902, and is the next bullet's now: #882 gave it a
294
+ * setup-pose clause whose subject is the bones, beside the stepping clause
295
+ * whose subject is the animations.
295
296
  * * An assertion with more than one clause SKIPs only when EVERY clause had
296
297
  * nothing to measure, which is the shape `A09`, `A33` and `A38` already
297
- * carry (`polygons.length === 0 && endsChecked === 0`). `A13_MESH_BUDGET` is
298
- * why the clause is stated: a declared slot budget is measured against a
299
- * count of mesh slots, and zero is a count, so that half passes on a rig
300
- * with no mesh — while a rig that declares only a TRIANGLE budget has
301
- * nothing left to measure and skips.
298
+ * carry (`polygons.length === 0 && endsChecked === 0`), and `A10` since #902.
299
+ * `A13_MESH_BUDGET` is why the clause is stated: a declared slot budget is
300
+ * measured against a count of mesh slots, and zero is a count, so that half
301
+ * passes on a rig with no mesh — while a rig that declares only a TRIANGLE
302
+ * budget has nothing left to measure and skips.
302
303
  *
303
304
  * ⚠️ What the criterion is not allowed to become is a table of assertions with
304
305
  * their verdicts written beside them. Every row above is decided by reading the
@@ -318,6 +319,13 @@ export const SKIP_NO_ATLAS = 'the round trip did not produce an atlas to measure
318
319
  export const SKIP_NO_REGION_ATTACHMENT = 'the skeleton carries no region attachment';
319
320
  export const SKIP_NO_MESH_ATTACHMENT = 'the skeleton carries no mesh attachment';
320
321
  export const SKIP_NO_ANIMATION = 'the skeleton carries no animation';
322
+ /**
323
+ * A10's (issue #902): its setup clause reads the bones and its stepping clause
324
+ * the animations, so it skips only when the skeleton carries neither — and the
325
+ * sentence is `SKIP_NO_ANIMATION`'s with the second subject added, so the two
326
+ * cannot drift into different words for the same absence.
327
+ */
328
+ export const SKIP_NO_POSE = `${SKIP_NO_ANIMATION} and no bone, so there is no setup pose to read and nothing to step`;
321
329
  export const SKIP_NO_TIMELINE = 'no animation here carries a timeline';
322
330
  export const SKIP_NO_PHYSICS_CONSTRAINT = 'the skeleton declares no physics constraint';
323
331
  export const SKIP_NO_ATLAS_PAGE = 'the atlas declares no page';
@@ -4336,13 +4344,31 @@ export function validate(input: ValidateInput): ValidateReport {
4336
4344
 
4337
4345
  // --- A10: step every animation and look for NaN ------------------------
4338
4346
  check('A10_NO_NAN_AFTER_STEPPING', () => {
4339
- // The name carries a subject as well as a construct, and A15 is the
4340
- // pattern (#580): the NaN is produced by STEPPING, which happens once per
4341
- // animation, so a skeleton with none has not been stepped and has no pose
4342
- // to find non-finite. A09 already reads the same subject this way — a
4343
- // static rig has no duration to compare — and the two must not print
4344
- // different verdicts over one skeleton's empty animation list.
4345
- if (data.animations.length === 0) return skip('A10_NO_NAN_AFTER_STEPPING', SKIP_NO_ANIMATION);
4347
+ // Two clauses, and since issue #902 each is read on its own subject. The
4348
+ // SETUP POSE is posed from the bones, so it has something to read on any
4349
+ // skeleton that carries one; the STEPPED FRAMES are posed once per
4350
+ // animation, so a skeleton with none has nothing to step. This used to
4351
+ // skip the whole rule on the second fact alone, which was A15's pattern
4352
+ // (#580) for a rule whose only clause was the stepping — and #882 gave it
4353
+ // the setup clause without moving the skip. Measured on the static probe
4354
+ // with a leaf bone at `rotation: 1e309` in its emitted file: `validate`
4355
+ // printed this rule as SKIP and the run green under both profiles, and
4356
+ // `render` refused the same file by the bone. A static rig's setup pose
4357
+ // is the whole of what it shows, so that was the one pose nothing read.
4358
+ //
4359
+ // ⇒ The multi-clause rule of #580 now governs, the shape A09, A13, A33
4360
+ // and A38 carry: the rule SKIPs only when neither clause has anything to
4361
+ // measure, the clause that ran decides PASS or FAIL, and the clause that
4362
+ // had nothing is named on the stats line (`nanStepping=skipped`, beside
4363
+ // `animations=0`) the way A47 names a constraint it did not measure. A
4364
+ // PASS row carries no detail, which is why the stats line is where.
4365
+ //
4366
+ // ⚠️ A09 does not follow, and that is its own name read at its word: a
4367
+ // declared duration is a fact about an animation and nothing else, so a
4368
+ // static rig still gives it nothing at all.
4369
+ if (data.animations.length === 0 && data.bones.length === 0) {
4370
+ return skip('A10_NO_NAN_AFTER_STEPPING', SKIP_NO_POSE);
4371
+ }
4346
4372
  /** Is this a mode `updateWorldTransform`'s switch has a case for? Read off the runtime's own enum. */
4347
4373
  const isMode = (inherit: unknown): boolean => typeof inherit === 'number' && Inherit[inherit] !== undefined;
4348
4374
 
@@ -4421,6 +4447,73 @@ export function validate(input: ValidateInput): ValidateReport {
4421
4447
  // below is posed AFTER `state.apply`, so a bone the animation keys from
4422
4448
  // t=0 never shows its setup value to the loop, and a runtime that shows
4423
4449
  // the rig at rest does show it.
4450
+ //
4451
+ // 🔸 Posed once, before any animation is set, rather than once per
4452
+ // animation as it was until #902: `setAnimation` does not touch the
4453
+ // skeleton, so every animation's copy of this pose was the same pose, and
4454
+ // the first of them was the only one ever read.
4455
+ if (data.animations.length === 0) stats.nanStepping = 'skipped';
4456
+ const atRest = new Skeleton(data);
4457
+ atRest.setupPose();
4458
+ atRest.update(0);
4459
+ atRest.updateWorldTransform(Physics.reset);
4460
+ const atSetup = nonFiniteOfPosed('the setup pose', atRest);
4461
+ if (atSetup !== null) {
4462
+ fail('A10_NO_NAN_AFTER_STEPPING', atSetup);
4463
+ return;
4464
+ }
4465
+
4466
+ /**
4467
+ * What a posed frame shows that the world-transform scan does not read: a
4468
+ * bone posing no inheritance mode, and a slot colour that is not finite.
4469
+ * `where` leads the sentence — the animation's name at a stepped frame,
4470
+ * as it always has, or `the setup pose` on a skeleton with no animation.
4471
+ */
4472
+ const poseDefect = (where: string, skeleton: Skeleton): string | null => {
4473
+ for (const bone of skeleton.bones) {
4474
+ const pose = bone.appliedPose;
4475
+ // The setup half of the inherit-key clause above: a bone's own
4476
+ // `inherit` goes through the same lookup, and a miss there loads
4477
+ // `undefined` into the setup pose, which every frame copies. Only
4478
+ // reachable on a file rigc did not write — `parseRigSpec` refuses the
4479
+ // spelling.
4480
+ if (!isMode(pose.inherit)) {
4481
+ const rawBone = Array.isArray(raw?.bones)
4482
+ ? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === bone.data.name)
4483
+ : undefined;
4484
+ return (
4485
+ `${where}: bone "${bone.data.name}" poses inheritance mode ${String(pose.inherit)} — its setup ` +
4486
+ `spells inherit ${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}, which the ` +
4487
+ `runtime's mode lookup does not resolve (${BONE_INHERIT_KNOWN}), so its world rotation, scale and shear ` +
4488
+ 'are never computed — they stay 0 and everything the bone carries collapses to a point'
4489
+ );
4490
+ }
4491
+ }
4492
+ for (const slot of skeleton.slots) {
4493
+ const c = slot.appliedPose.color;
4494
+ if (![c.r, c.g, c.b, c.a].every(Number.isFinite)) return `${where}: slot "${slot.data.name}" colour is non-finite`;
4495
+ // The other colour a slot poses, and it was outside this loop until
4496
+ // issue #690 for the reason every gap here has: nothing emitted one.
4497
+ // `null` is the ordinary case — a slot with no `dark` allocates no
4498
+ // dark colour at all — and is not a reading to make, so it is
4499
+ // skipped rather than treated as zero.
4500
+ const d = slot.appliedPose.darkColor;
4501
+ if (d !== null && ![d.r, d.g, d.b].every(Number.isFinite)) {
4502
+ return `${where}: slot "${slot.data.name}" dark colour is non-finite`;
4503
+ }
4504
+ }
4505
+ return null;
4506
+ };
4507
+
4508
+ // The stepping half, and on a static rig there is nothing to step: the
4509
+ // setup pose is then the only frame the skeleton has, and the two readings
4510
+ // a stepped frame gets are made on it instead, so a colour or an
4511
+ // inheritance mode the rig shows at rest is not left to `render`.
4512
+ if (data.animations.length === 0) {
4513
+ const atRestDefect = poseDefect('the setup pose', atRest);
4514
+ if (atRestDefect !== null) fail('A10_NO_NAN_AFTER_STEPPING', atRestDefect);
4515
+ return;
4516
+ }
4424
4517
  for (const anim of data.animations) {
4425
4518
  const skeleton = new Skeleton(data);
4426
4519
  const state = new AnimationState(new AnimationStateData(data));
@@ -4428,11 +4521,6 @@ export function validate(input: ValidateInput): ValidateReport {
4428
4521
  skeleton.setupPose();
4429
4522
  skeleton.update(0);
4430
4523
  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
- }
4436
4524
  const step = Math.max(anim.duration, 1) / STEP_FRAMES;
4437
4525
  for (let i = 0; i < STEP_FRAMES; i++) {
4438
4526
  state.update(step);
@@ -4447,42 +4535,10 @@ export function validate(input: ValidateInput): ValidateReport {
4447
4535
  fail('A10_NO_NAN_AFTER_STEPPING', found);
4448
4536
  return;
4449
4537
  }
4450
- for (const bone of skeleton.bones) {
4451
- const pose = bone.appliedPose;
4452
- // The setup half of the clause above: a bone's own `inherit` goes
4453
- // through the same lookup, and a miss there loads `undefined` into
4454
- // the setup pose, which every frame copies. Only reachable on a
4455
- // file rigc did not write — `parseRigSpec` refuses the spelling.
4456
- if (!isMode(pose.inherit)) {
4457
- const rawBone = Array.isArray(raw?.bones)
4458
- ? (raw.bones as unknown[]).find((b) => isObj(b) && b.name === bone.data.name)
4459
- : undefined;
4460
- fail(
4461
- 'A10_NO_NAN_AFTER_STEPPING',
4462
- `${anim.name}: bone "${bone.data.name}" poses inheritance mode ${String(pose.inherit)} — its setup ` +
4463
- `spells inherit ${JSON.stringify(isObj(rawBone) ? rawBone.inherit : undefined)}, which the ` +
4464
- `runtime's mode lookup does not resolve (${BONE_INHERIT_KNOWN}), so its world rotation, scale and shear ` +
4465
- 'are never computed — they stay 0 and everything the bone carries collapses to a point',
4466
- );
4467
- return;
4468
- }
4469
- }
4470
- for (const slot of skeleton.slots) {
4471
- const c = slot.appliedPose.color;
4472
- if (![c.r, c.g, c.b, c.a].every(Number.isFinite)) {
4473
- fail('A10_NO_NAN_AFTER_STEPPING', `${anim.name}: slot "${slot.data.name}" colour is non-finite`);
4474
- return;
4475
- }
4476
- // The other colour a slot poses, and it was outside this loop until
4477
- // issue #690 for the reason every gap here has: nothing emitted one.
4478
- // `null` is the ordinary case — a slot with no `dark` allocates no
4479
- // dark colour at all — and is not a reading to make, so it is
4480
- // skipped rather than treated as zero.
4481
- const d = slot.appliedPose.darkColor;
4482
- if (d !== null && ![d.r, d.g, d.b].every(Number.isFinite)) {
4483
- fail('A10_NO_NAN_AFTER_STEPPING', `${anim.name}: slot "${slot.data.name}" dark colour is non-finite`);
4484
- return;
4485
- }
4538
+ const defect = poseDefect(anim.name, skeleton);
4539
+ if (defect !== null) {
4540
+ fail('A10_NO_NAN_AFTER_STEPPING', defect);
4541
+ return;
4486
4542
  }
4487
4543
  }
4488
4544
  }