spine-rigc 0.33.1 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/cli.ts CHANGED
@@ -86,7 +86,7 @@ import {
86
86
  type AtlasRegion,
87
87
  } from './src/atlas.ts';
88
88
  import { parseJsonWithPosition } from './src/json-position.ts';
89
- import { KEY_TIME_EPSILON } from './src/timelines.ts';
89
+ import { float32Step } from './src/timelines.ts';
90
90
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
91
91
  import {
92
92
  DEFAULT_MAX_RESIDUAL,
@@ -623,8 +623,30 @@ function meshFit(m: CompileResult['meshes'][number]): string {
623
623
  // why, in the place the figures stood, and prints no figure.
624
624
  if (m.fitWithheld !== undefined) return ` fit not measured: ${m.fitWithheld}. ${PAGE_GRID_UNLOCATED}`;
625
625
  if (m.coverage === undefined) return '';
626
- const hole = m.holePixels ? `, enclosing ${m.holePixels}px of hole` : '';
627
- return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${hole}`;
626
+ // A count of the plate's own cells, so on a `scale:` page it is texels and
627
+ // says so rather than borrowing the overshoot's unit beside it (issue #762).
628
+ const hole = m.holePixels ? `, enclosing ${m.holePixels}${m.pageScale === undefined ? 'px' : ' texel(s)'} of hole` : '';
629
+ return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${meshFitGrid(m)}${hole}`;
630
+ }
631
+
632
+ /**
633
+ * The grid a fit was taken on, when it is not the drawing's own (issue #762).
634
+ *
635
+ * The overshoot is stated in the drawing's pixels on every route — the unit an
636
+ * attachment's size is in — and on a page that declares a `scale:` other than
637
+ * 1 it was measured on the page's texels and divided by that scale. So it
638
+ * carries the coarser grid's step: on `scale: 0.5` a figure moves in steps of
639
+ * 2.00px of the drawing, and it need not equal the figure the page it was
640
+ * packed from reads except where the distance falls on whole texels. Said
641
+ * beside the figure, and nothing at all on a loose part or a page at scale 1,
642
+ * where the line is the one it always was.
643
+ */
644
+ function meshFitGrid(m: CompileResult['meshes'][number]): string {
645
+ if (m.pageScale === undefined) return '';
646
+ return (
647
+ ` (the drawing's pixels, measured on the page's texels at scale: ${m.pageScale} — a texel is ` +
648
+ `${(1 / m.pageScale).toFixed(2)}px of the drawing)`
649
+ );
628
650
  }
629
651
 
630
652
  /**
@@ -739,12 +761,14 @@ function deformKeyName(key: DeformKeyMeasure): string {
739
761
  * The report's is the spec's own `t`; the survey's came back through
740
762
  * `Float32Array`, because that is what `spine-core` reads a timeline's frames
741
763
  * into — a key written `0.62` arrives as `0.6200000047683716`. So the tolerance
742
- * is the compiler's own key-time grid plus one float32 ulp at this magnitude,
743
- * which is narrower than any key spacing the format can hold and wide enough for
744
- * both roundings.
764
+ * is one float32 step at this magnitude (`float32Step`): the compiler emits a
765
+ * key time as a float's name — the spec's own time when it names one, the float
766
+ * below it when it does not (issue #716) — so the loaded float is within one
767
+ * step of the spec's `t` either way, which is narrower than any key spacing the
768
+ * format can hold.
745
769
  */
746
770
  function sameKeyTime(specTime: number, loaded: number): boolean {
747
- return Math.abs(specTime - loaded) <= KEY_TIME_EPSILON + Math.abs(loaded) * 2 ** -23;
771
+ return Math.abs(specTime - loaded) <= float32Step(loaded);
748
772
  }
749
773
 
750
774
  /**
package/docs/AUTHORING.md CHANGED
@@ -324,6 +324,14 @@ texel count beside it so both numbers are visible:
324
324
  measures the PNG. Reach for `--atlas-in` when the pack is what you were handed, or
325
325
  when drawing through the pack's own texels is the point.
326
326
 
327
+ 📐 **What you make beside the art stays at the art's size**
328
+ ([#762](https://github.com/firejune/rigc/issues/762)). A depth sheet or a soft
329
+ mask is read in the drawing's pixels on a `scale:` page as on loose parts — each
330
+ vertex's texel position over the stated scale — and a mesh fit's overshoot is
331
+ printed in them, with the texel it was measured on named beside it. What stays
332
+ in texels is what is taken off them: a `contour`'s trace, whose `margin` and
333
+ `tolerance` are applied on the texels there are (below).
334
+
327
335
  🚨 **A page that declares a size it does not have is a different thing, and it is
328
336
  refused** ([#715](https://github.com/firejune/rigc/issues/715)). The common shape
329
337
  is a pack whose `4096x4096` pages ship as `2048x2048` PNGs with the atlas
@@ -387,11 +395,34 @@ that cannot be withheld, because its outline *is* its geometry: it is refused as
387
395
  compile error carrying `A06`'s whole sentence — ratio and repair — on `explain`
388
396
  and on `build` alike, where on `build` it arrives before the gate would have said
389
397
  it. Carry out the repair and every figure comes back. ⚠️ They come back measured
390
- on the **coarser** texels the page really has, so they need not equal the figures
391
- of the pack the page was halved from: on the same fixture the `scale: 0.5`
392
- restatement reads the authored mesh at 100.00% coverage reaching **8.00px** past
393
- it (the overshoot is counted in the page's own texels), and traces the contour as
394
- 11 vertices where the full-resolution page traced 15.
398
+ on the **coarser** texels the page really has, so a figure that depends on the
399
+ grid need not equal the one the pack the page was halved from reads: on the same
400
+ fixture the `scale: 0.5` restatement traces the contour as 11 vertices where the
401
+ full-resolution page traced 15, because a trace runs on the texels there are and
402
+ its `margin` and `tolerance` are applied on them.
403
+
404
+ 📐 **A fit's overshoot is stated in the drawing's pixels on every page**
405
+ ([#762](https://github.com/firejune/rigc/issues/762)). It is a distance, and on a
406
+ page that declares a `scale:` it is taken on the page's texels — so it used to be
407
+ printed in them, under the same `px`: one mesh over one drawing read 16.00px on
408
+ the declared-size page, **8.00px** on its `scale: 0.5` restatement and **32.00px**
409
+ on a `scale: 2` one. It is now the texel distance over the scale the atlas states,
410
+ which is the unit an attachment's `width` is in and the one you drew in, and the
411
+ line says which grid it was taken on — a texel of a `scale: 0.5` page is 2.00px of
412
+ the drawing, so that is the step the figure moves in:
413
+
414
+ ```bash
415
+ # fan authored 9 vertices / 8 triangles (budget 200) bones=[fan] covers 100.00% of the art, reaching 16.00px
416
+ # past it (the drawing's pixels, measured on the page's texels at scale: 0.5 — a texel is 2.00px of the drawing)
417
+ ```
418
+
419
+ All three pages read the fan at 16.00px, because its rim lands on whole texels of
420
+ each. A figure that does not is exact only to that step — the contour above reads
421
+ 6.00px on the `scale: 0.5` page (3.00 texels) against the full page's 3.16px, a
422
+ different outline measured on a coarser grid, and the line's clause is what says
423
+ so. On a loose part and a page at scale 1 the texels are the drawing, and the line
424
+ is the one it always was. A `contour`'s hole count is a count of those cells and
425
+ says `texel(s)` on such a page.
395
426
 
396
427
  🚨 **A page that is not a PNG is refused by name, before anything is compiled
397
428
  against it** ([#732](https://github.com/firejune/rigc/issues/732)). rigc reads PNG
@@ -707,7 +738,10 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
707
738
  count of vertices on undrawn texels — are replaced by `fit not measured: …` and
708
739
  `… is not measured: …` naming the page and the ratio. A `contour` on such a page
709
740
  is refused, since its outline is read off those texels. Why, the quoted lines,
710
- and the repair: §0.2.
741
+ and the repair: §0.2. On a page that **does** declare a `scale:`, the overshoot
742
+ in that line is the drawing's pixels and the line names the texel it was taken
743
+ on (`… measured on the page's texels at scale: 0.5 — a texel is 2.00px of the
744
+ drawing`, [#762](https://github.com/firejune/rigc/issues/762)).
711
745
  - **`diff`** compares two skeletons and reports **a ratio per measure** in six
712
746
  sections (bones, slots, attachments, constraints, animations, events). It
713
747
  deliberately does not combine them into a score: a rig with the right skeleton
@@ -922,7 +956,7 @@ one frame (1/60 s) is a compile error, and assertion `A09` re-checks it against
922
956
  That frame of slack is for a duration declared *longer* than the motion — an
923
957
  animation may hold its final pose. In the other direction there is no slack to give:
924
958
  **no key may land past the declared duration**, and this is checked per timeline
925
- rather than per animation, within 1e-6 s. Both halves matter, and the second is not
959
+ rather than per animation, within one float32 step of the duration. Both halves matter, and the second is not
926
960
  the first with a smaller number — see §4.5.
927
961
 
928
962
  **R8 — `from` needs a cut manifest.** `from.anchor` / `from.slotWindow` /
@@ -1092,7 +1126,7 @@ number where the box would be:
1092
1126
  | --- | --- | --- |
1093
1127
  | `build` | emits `x`/`y`/`width`/`height` | emits none of them |
1094
1128
  | `A14_NO_FULL_FRAME_MESH` | fails a mesh as big as the stage | **SKIP**, by name |
1095
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | exempts the one image that covers the stage | exempts nothing, and says so |
1129
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | exempts the base plate a cut manifest names — the part whose window is the crop — and, on a build that names none, the one image that covers the stage | a manifest build exempts the plate it names, exactly as with one ([#770](https://github.com/firejune/rigc/issues/770)). A rig-spec build names no base plate, so an opaque part is refused with *"this skeleton declares no stage size to measure one against, and the build names no base plate"* and the two ways to decide it: a `skeleton` stage the plate covers, or *"build from a cut manifest, whose base plate is the part whose window is the crop"* |
1096
1130
  | `diff` | `stage_present` and `stage_box` | `stage_present` 1/1 when neither side declares one (agreement), `stage_box` 0/0 |
1097
1131
  | `explain` | `stage W x H` | `stage none declared` |
1098
1132
  | `render` | frames the posed extent of every animation | the same frames, plus a line saying the viewport is the posed extent and no stage |
@@ -1869,7 +1903,7 @@ off a greyscale sheet in the part's own pixel grid:
1869
1903
 
1870
1904
  | Field | Meaning |
1871
1905
  | --- | --- |
1872
- | `image` | **required.** The sheet, relative to the rig's `images` directory, and the **same pixel size as this attachment's `image`**. It is not packed into the atlas — it is a measurement rigc reads at compile time, not art anything draws |
1906
+ | `image` | **required.** The sheet, relative to the rig's `images` directory, and the **same pixel size as this attachment's `image`**. It is not packed into the atlas — it is a measurement rigc reads at compile time, not art anything draws. Under `--atlas-in` that is still the drawing's size, on a page that declares a `scale:` too: the sheet is read at each vertex's texel position over the scale the atlas states, so one sheet serves the loose parts and every pack of them ([#762](https://github.com/firejune/rigc/issues/762)) |
1873
1907
  | `near` | **required.** `"white"` or `"black"` — which end of the range is closest to the viewer. Stated rather than defaulted: both conventions are in use, and a sheet read with the wrong one turns the part inside out with every gate still green |
1874
1908
  | `zScale` | **required.** How many world units the map's full range spans, in the attachment's own units — the number `radius` used to carry. 8 bits of level say nothing about scale, so this is authored, never measured |
1875
1909
  | `gamma`, `contrast`, `bias` | the tone curve applied to the nearness, defaults `1` / `1` / `0`. State them when a consumer's own renderer curves the same sheet, so the mesh and that renderer describe one surface |
@@ -1936,7 +1970,7 @@ bun cli.ts build --rig gallery/look/rig.json \
1936
1970
  1st pct yaw +19.32° x1.000 of 80 / -19.32° x1.000 of 80 pitch +22.92° x1.000 of 102 / -26.94° x1.000 of 130
1937
1971
  first to fold: yaw + at 19.32°, triangle 174 [119,138,139], the sheet steps 28.50 level(s) across it, which is 0.112 of the range this mesh sampled
1938
1972
  MESH hair_lock_l grid 39 vertices / 48 triangles (budget 320) bones=[lock_l] attachments=[hair_lock_l]
1939
- depth "lock_l_depth.png" 0c4eaeb36b7c5cac near=white zScale=64 z=[22.086275, 63.874511]
1973
+ depth "lock_l_depth.png" 0c4eaeb36b7c5cac near=white zScale=64 z=[22.086275, 63.87451]
1940
1974
  32 of 39 vertices sample a texel the part image does not draw — their z is the sheet's reading of somewhere the part is not
1941
1975
  turn ceiling yaw +17.04° / -45.80° pitch +none / -none
1942
1976
  1st pct yaw +unranked of 12 / -unranked of 36 pitch +none / -none
@@ -2034,6 +2068,8 @@ refuses it instead:
2034
2068
  | a sheet cut to the art's alpha, on a **contour** | `does not cover 12 of the mesh's 12 vertices … A contour mesh puts every vertex ON the silhouette and pushes it out by the margin … Dilate the sheet past the mesh margin, or lower the margin.` |
2035
2069
  | a sheet cut to the art's alpha, on a **grid** | `does not cover 36 of the mesh's 81 vertices … A grid spans the whole part window, corners included … Dilate the sheet to the window, or state "us"/"vs" that keep the lattice inside the art.` — the two topologies run out of sheet for different reasons, and the message says which |
2036
2070
  | a sheet that is not the part's size | `the depth map … is 32x32 and the part is 64x64. A depth map is sampled in the part's own pixel grid` |
2071
+ | a sheet at the page's texel size, on a page that declares a `scale:` | `the depth map "lattice_texels.png" is 48x32 and the part is a 96x64 drawing — 48x32 texels on a page that declares scale: 0.5, which makes a texel 2px of the drawing. … so the sheet is 96x64, the size of the loose art, whatever the page holds.` — the part's own pixel grid is the drawing's on every route, and the message names the ratio and all three sizes, because the drawing's is in neither file |
2072
+ | a sheet cut to the art, on a `scale:` page | the coverage refusal above, with the vertex's position in the page's texels **and** `pixel (x, y) of the drawing-sized sheet` — the pixel of the file you made |
2037
2073
  | a colour sheet | `the depth map … is not greyscale — pixel (0, 0) is rgb(10, 200, 10)` |
2038
2074
  | `zScale` at or below 0 | `it is how many units the map's full range spans, so a positive number. To put the near end at the back, say "near": "black"` |
2039
2075
  | `gamma` or `contrast` at or below 0 | `collapses the range onto the midpoint … so the map would describe a flat part` |
@@ -2101,7 +2137,7 @@ hanging sleeve.
2101
2137
  | Field | Meaning |
2102
2138
  | --- | --- |
2103
2139
  | `bone` | **required.** The bone the region is carried by. It has to already exist — a bone a physics constraint targets is part of the skeleton, not a side effect of a mesh |
2104
- | `mask` | **required.** A greyscale sheet in the part's own pixel grid: the level IS the weight, black still and white fully carried, sampled at each vertex. Alpha is not read — a transparent pixel is black |
2140
+ | `mask` | **required.** A greyscale sheet in the part's own pixel grid: the level IS the weight, black still and white fully carried, sampled at each vertex. Alpha is not read — a transparent pixel is black. The grid is the drawing's, as for a depth map: on a `scale:` page the mask is still the art's size ([#762](https://github.com/firejune/rigc/issues/762)) |
2105
2141
 
2106
2142
  The remainder always stays on the slot bone, so every vertex closes at 1 by
2107
2143
  construction rather than by `A20` catching it later. `build` and `explain` report
@@ -2131,7 +2167,7 @@ bone, must close at 1, and at least one must actually be carried.
2131
2167
  | the slot's own bone | `moves nothing — a soft region needs a bone that can move independently` |
2132
2168
  | a mask that is black everywhere | `carries no vertex of this mesh — every one of its 49 vertices samples black` |
2133
2169
  | a colour mask | `is not greyscale — pixel (0, 0) is rgb(10, 200, 10)` |
2134
- | a mask that is not the part's size | `is 48x32 and the part is 96x64` |
2170
+ | a mask that is not the part's size | `is 48x32 and the part is 96x64` — on a `scale:` page, `and the part is a 96x64 drawing — 48x32 texels on a page that declares scale: 0.5` |
2135
2171
  | a mask that is not on disk | `the soft mask "x.png" is not at …` |
2136
2172
 
2137
2173
  ⭐ **One depth pass buys both, on one part.** A carried mesh has two bones on the
@@ -2987,10 +3023,11 @@ the mapping above and drives the bone to the value it names, rather than playing
2987
3023
  the animation on a track while the slider sits at its neutral. §4.11.4 is what
2988
3024
  that changes and why it matters at `mix: 1`.
2989
3025
 
2990
- 🔸 **`scale` is rounded to six decimals on emit**, like every other number rigc
2991
- writes, so `1/60` ships as `0.016667` — 2e-5 relative. Invisible in the middle of
2992
- the range; it shows at the top of it, where a 60° turn then applies at 1.00002 s
2993
- rather than 1 s. With `loop: false` that is the last frame and harmless, with
3026
+ 🔸 **`scale` is emitted as its float32**, like every other number rigc writes, so
3027
+ `1/60` ships as `0.016666668` — 8e-8 relative (it was `0.016667`, 2e-5, on the
3028
+ six-decimal grid before issue #716). Invisible in the middle of the range; it
3029
+ shows at the top of it, where a 60° turn then applies at 1.00000008 s rather than
3030
+ 1 s. With `loop: false` that is the last frame and harmless, with
2994
3031
  `loop: true` it wraps to the start. When the range comes from a *measured* ceiling
2995
3032
  — the turn ceiling `build` reports for a depth mesh (§3.4, `depth`) is the natural
2996
3033
  one — pick `to`/`scale` so the endpoint lands **inside** the duration rather than
@@ -3561,26 +3598,33 @@ consumer's, decided by dressing the skeleton rather than by the animation.
3561
3598
  Seconds, not frames: nothing requires a key to land on any frame grid, and a
3562
3599
  reference rendered at some rate says nothing about where its keys are. Put keys
3563
3600
  where the motion changes.
3564
- - **Key times are quantised onto a 1e-6 s grid by rounding DOWN, never to
3565
- nearest.** A key time is a position against the sample grid a player will step,
3601
+ - **Key times are emitted as float32s like every other number, and never stored
3602
+ LATER than you wrote them.** Every emitted number is the shortest decimal naming
3603
+ its float32 — the text the editor writes, and the precision the runtime keeps,
3604
+ because `spine-core` reads a timeline's frames into a `Float32Array` (issue #716).
3605
+ A time that already names a float — `0.5`, `0.2`, the editor's `1.4333333` — is
3606
+ written as you wrote it. A time that does not — `2/12` computed in doubles, a key
3607
+ moved by `lag` or `stagger` — steps to the **largest float not above it**, never
3608
+ to nearest. A key time is a position against the sample grid a player will step,
3566
3609
  and the two directions of a half-step error are not the same size. `2/12 s` and
3567
- `5/30 s` are both 0.16666666…; `0.166667` is *larger* than either, so a key
3568
- emitted there is applied at sample **3** of a 12 fps playback and not sample 2 —
3569
- a whole frame late, with nothing raised. On a **stepped** timeline (an attachment
3570
- timeline always is) that is the wrong picture rather than a slightly wrong value:
3571
- the spineboy run's muzzle flare fired a frame late for exactly this until the
3572
- run's own frame check caught it (issue #99). Rounding down cannot do that; the
3573
- worst it can do is put a key a millionth of a second early, on the sample it was
3574
- written for. ⚠️ What this does **not** protect you from is rounding your own
3575
- times before you write them — write `2/12`, not `0.1667`, and let the compiler
3610
+ `5/30 s` are both 0.16666666…, and the nearest float to that, 0.1666666716…, is
3611
+ *larger* than either, so a key stored there is applied at sample **3** of a 12 fps
3612
+ playback and not sample 2 — a whole frame late, with nothing raised. On a
3613
+ **stepped** timeline (an attachment timeline always is) that is the wrong picture
3614
+ rather than a slightly wrong value: the spineboy run's muzzle flare fired a frame
3615
+ late for exactly this, on the six-decimal grid rigc emitted until #716, until the
3616
+ run's own frame check caught it (issue #99). Stepping down cannot do that; the
3617
+ worst it can do is store a key one float early — 1.5e-8 s at 1/6 s — on the sample
3618
+ it was written for. ⚠️ What this does **not** protect you from is rounding your
3619
+ own times before you write them — write `2/12`, not `0.1667`, and let the compiler
3576
3620
  do the quantising.
3577
- - 🚨 **Nor does it protect a stepped key whose time is ALREADY on the 1e-6 grid.**
3578
- Rounding down leaves such a time exactly where you wrote it, and the sampler does
3579
- not arrive there: a player — and `sampleAnimation`, and therefore `check` — reaches
3580
- sample *i* by accumulating `1/fps` *i* times, which for many *i* lands a few ULPs
3581
- **below** `i/fps`. `2/12` is saved by the rule above precisely because it is *not*
3582
- on the grid; `0.25`, `0.5`, `0.75`, `1` and every other multiple of `0.25 s` is, and
3583
- a stepped key there sits above the sample that was meant to see it. On an
3621
+ - 🚨 **Nor does it protect a stepped key whose time ALREADY names a float.**
3622
+ Such a time is written exactly as you wrote it, and the sampler does not arrive
3623
+ there: a player — and `sampleAnimation`, and therefore `check` — reaches sample *i*
3624
+ by accumulating `1/fps` *i* times, which for many *i* lands a few ULPs **below**
3625
+ `i/fps`. `2/12` is saved by the rule above precisely because it is *not* a float;
3626
+ `0.25`, `0.5`, `0.75`, `1` and every other multiple of `0.25 s` is one exactly,
3627
+ and a stepped key there sits above the sample that was meant to see it. On an
3584
3628
  interpolated timeline that costs a few ULPs of value and nothing else. On a
3585
3629
  **stepped** one it is the whole frame — and on the last sample it is the whole
3586
3630
  event, because there is no later sample to catch it. Measured on rung 5's 6.5 s
@@ -3590,16 +3634,22 @@ consumer's, decided by dressing the skeleton rather than by the animation.
3590
3634
  `6.499999999999994` — which read as a frame-change disagreement the pose series had
3591
3635
  already fixed, and cost that run three builds
3592
3636
  ([`2026-08-26-rung5-1`](https://github.com/firejune/rigc/blob/main/bench/runs/2026-08-26-rung5-1/LOOP.md), §8). ⇒ **For a
3593
- stepped timeline, write `T − 1e-6` rather than `T`.** One grid step early cannot
3594
- reach the previous sample — 83,333 µs away at 12 fps — and is always seen by the
3595
- sample it was written for; one ULP late loses the frame. This is the same asymmetry
3596
- the rule above turns on, one grid step further in.
3637
+ stepped timeline, write a time a little below `T` — `T − 1e-6` still works, at
3638
+ any magnitude — rather than `T`.** What makes it work is not the size of the step:
3639
+ any time below `T` is stored on a float below `T`, because a time that names a
3640
+ float is stored at that float and one that does not steps down, and the float
3641
+ below `T` is below the accumulated sample too (one float step is 3.0e-8 s at
3642
+ 0.5 s and 4.8e-7 s at 6.5 s, against the few-ULP shortfall of the sampler). What
3643
+ bounds it from the other side is the frame: `T − 1e-6` cannot reach the previous
3644
+ sample, 83,333 µs away at 12 fps. One ULP late loses the frame. This is the same
3645
+ asymmetry the rule above turns on, one float further in.
3597
3646
  - **No key may land past the animation's `duration`.** Nothing that plays the
3598
3647
  animation for the duration it declares ever reaches such a key, so it is a
3599
3648
  compile error — checked on **every timeline**, not just on the latest key in the
3600
- animation. The tolerance is 1e-6 s, which is one step of the grid rigc rounds key
3601
- times onto, so a key you put exactly *on* a duration that is not a round number
3602
- of microseconds is fine. R7's frame of slack does not apply in this direction and
3649
+ animation, against the float the key is stored at. The tolerance is one float32
3650
+ step at the duration — 4.8e-7 s at 5 s, 3.8e-6 s at 32 s — which is the most a
3651
+ key you put exactly *on* a duration the float cannot hold is stored past it, so
3652
+ such a key is fine. R7's frame of slack does not apply in this direction and
3603
3653
  would not see this: rung 6 rounded its key times to 4 dp somewhere upstream, its
3604
3654
  one-frame reveal landed 0.000034 s past a 68/12 s duration, another track was
3605
3655
  already sitting on the declared duration so the animation's *longest* key time
@@ -3787,7 +3837,7 @@ group members (the per-member values of one track, side by side — issue #295)
3787
3837
  t = 0.20944 rad
3788
3838
  cos t − 1 = -0.021852
3789
3839
  sin t = 0.207912
3790
- shift the parent carries = −carried·sin t = -35.344987
3840
+ shift the parent carries = −carried·sin t = -35.344986
3791
3841
  eye_l 5.513083 <- -62 at depth 150
3792
3842
  eye_r 2.803385 <- 62 at depth 150
3793
3843
  brow_l 3.849789 <- -62 at depth 158
@@ -4443,15 +4493,15 @@ bun cli.ts explain --rig gallery/portrait/rig.json \
4443
4493
  t = 0.20944 rad
4444
4494
  cos t − 1 = -0.021852
4445
4495
  sin t = 0.207912
4446
- centre shift = −radius·sin t = -35.344987
4447
- 25 vertices, largest offset 35.344987px at vertex 2
4448
- v 0 (-7.17493, 0) v 1 (-22.413595, 0) v 2 (-35.344987, 0) v 3 (-27.658171, 0)
4496
+ centre shift = −radius·sin t = -35.344986
4497
+ 25 vertices, largest offset 35.344986px at vertex 2
4498
+ v 0 (-7.17493, 0) v 1 (-22.413595, 0) v 2 (-35.344986, 0) v 3 (-27.65817, 0)
4449
4499
  v 4 (-14.255108, 0) v 5 (-14.255108, 0) v 6 (-14.255108, 0) v 7 (-14.255108, 0)
4450
- v 8 (-14.255108, 0) v 9 (-27.658171, 0) v 10 (-35.344987, 0) v 11 (-22.413595, 0)
4500
+ v 8 (-14.255108, 0) v 9 (-27.65817, 0) v 10 (-35.344986, 0) v 11 (-22.413595, 0)
4451
4501
  v 12 (-7.17493, 0) v 13 (-7.17493, 0) v 14 (-7.17493, 0) v 15 (-7.17493, 0)
4452
- v 16 (-22.413595, 0) v 17 (-35.344987, 0) v 18 (-27.658171, 0) v 19 (-22.413595, 0)
4453
- v 20 (-35.344987, 0) v 21 (-27.658171, 0) v 22 (-22.413595, 0) v 23 (-35.344987, 0)
4454
- v 24 (-27.658171, 0)
4502
+ v 16 (-22.413595, 0) v 17 (-35.344986, 0) v 18 (-27.65817, 0) v 19 (-22.413595, 0)
4503
+ v 20 (-35.344986, 0) v 21 (-27.65817, 0) v 22 (-22.413595, 0) v 23 (-35.344986, 0)
4504
+ v 24 (-27.65817, 0)
4455
4505
  ```
4456
4506
 
4457
4507
  The curve reads `stepped` where the spec says `"ease": "swell"`, and that is
@@ -4459,12 +4509,17 @@ The curve reads `stepped` where the spec says `"ease": "swell"`, and that is
4459
4509
  offsets, so the segment between them would draw nothing and is written the way
4460
4510
  the editor writes it.
4461
4511
 
4462
- 📌 **Float behaviour, stated.** The closed forms are evaluated in float64 and
4463
- quantised to six decimals like every other emitted number, so the same spec emits
4464
- the same bytes and `A18_DETERMINISTIC_EMIT` proves it on a second compile. The
4465
- runtime then loads those decimals into a `Float32Array`, which is equally true of
4466
- a hand-written table — the difference the generator makes is that the decimals
4467
- now agree with a stated model instead of with a transcription.
4512
+ 📌 **Float behaviour, stated.** The closed forms are evaluated in float64,
4513
+ quantised onto the model's own 1e-6 grid and emitted as that value's float32 name
4514
+ like every other number, so the same spec emits the same bytes and
4515
+ `A18_DETERMINISTIC_EMIT` proves it on a second compile. The grid is absolute on
4516
+ purpose: a model's identities — a wave sampled on its zero crossings, a whole
4517
+ revolution — are exact zeros float64 misses by ~1e-16, and the refusal of a key
4518
+ that states a deformation and evaluates to nothing is decided on it (issue #350);
4519
+ a float32 alone is relative and has no zero to land on. The runtime then loads
4520
+ the numbers into a `Float32Array`, which is equally true of a hand-written table —
4521
+ the difference the generator makes is that the numbers now agree with a stated
4522
+ model instead of with a transcription.
4468
4523
 
4469
4524
  🔭 **Both adjacent asks have since landed.**
4470
4525
  [#295](https://github.com/firejune/rigc/issues/295) was the same complaint about
@@ -5444,7 +5499,7 @@ Fix A00 and run it again ([#568](https://github.com/firejune/rigc/issues/568)).
5444
5499
  | `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) |
5445
5500
  | `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) — as it is for `A06`, `A19` and `A27`; see `A07` ([#608](https://github.com/firejune/rigc/issues/608)) |
5446
5501
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
5447
- | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the full-stage base plate may be opaque. Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)). ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page** ([#705](https://github.com/firejune/rigc/issues/705)): a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it** ([#715](https://github.com/firejune/rigc/issues/715)), and #705's clause does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so the scan came back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappeared**, the scan having found a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. Before #705 the walk was silent about its own reach, so a part nobody could read printed *opaque in every one of its 12x8 texels* over zero of them, which is a refusal pointing at the wrong file: the art it names may be transparent, and the repair is the rectangle in `A06`'s row above. ⚠️ **A page file that cannot be read as PNG at all is the same non-measurement for every part on it** ([#732](https://github.com/firejune/rigc/issues/732)): one row per page naming its parts and pointing at `A06`, which names what the file is — where it used to print `threw: cannot decode PNG …: unexpected end of file`, an inflate error about a file that was never a PNG. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
5502
+ | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part image can never be transparent: no alpha channel (colour type 4 or 6) and no `tRNS` chunk either, so it would paint a solid rectangle over what is behind it. Re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. Only the base plate may be opaque, and which image that is is decided once for both routes ([#770](https://github.com/firejune/rigc/issues/770)): the plate the build names — on a cut manifest, the part whose window is the crop — and, only when it names none, an image at least the stage's size. The rig's statement comes first because the two can disagree: a stage stated small enough for an overlay to cover would otherwise exempt that overlay. A rig spec cannot name a base plate, so a stageless rig-spec build, and `validate <dir>` on a stageless skeleton, has nothing to decide it; an opaque part there is refused with *"nothing here decides which image that is: this skeleton declares no stage size to measure one against"* and the two ways to decide it — a `skeleton` stage the plate covers, or a cut manifest (for `validate`, the specs it was built from: `--rig`, `--motion` and the `--manifest`). Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art ([#266](https://github.com/firejune/rigc/issues/266)). ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page** ([#705](https://github.com/firejune/rigc/issues/705)): a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it** ([#715](https://github.com/firejune/rigc/issues/715)), and #705's clause does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so the scan came back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappeared**, the scan having found a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. Before #705 the walk was silent about its own reach, so a part nobody could read printed *opaque in every one of its 12x8 texels* over zero of them, which is a refusal pointing at the wrong file: the art it names may be transparent, and the repair is the rectangle in `A06`'s row above. ⚠️ **A page file that cannot be read as PNG at all is the same non-measurement for every part on it** ([#732](https://github.com/firejune/rigc/issues/732)): one row per page naming its parts and pointing at `A06`, which names what the file is — where it used to print `threw: cannot decode PNG …: unexpected end of file`, an inflate error about a file that was never a PNG. **SKIP** when the atlas declares no page ([#580](https://github.com/firejune/rigc/issues/580)) |
5448
5503
  | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not ([#684](https://github.com/firejune/rigc/issues/684)). Fix the rig spec's `controls`/`chain`, or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
5449
5504
  | `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1 |
5450
5505
  | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment ([#580](https://github.com/firejune/rigc/issues/580)) |
@@ -7844,11 +7899,14 @@ under 4.3.26 (`[152.7006, 305.4012, 458.1019, 610.8025]`) and on an open one und
7844
7899
  ⚠️ **That last reading settles the model and cannot settle the spelling.** A
7845
7900
  4-sample chord sum agrees with the runtime's forward difference to about **nine
7846
7901
  significant digits** — *below* what float32 can hold, which is why both spellings
7847
- reproduce both exports exactly, and *above* the six decimals rigc emits, which is
7848
- why the file can tell them apart. On both rigs above they round apart on the
7849
- **last** curve, where the running total has accumulated most: `610.802519` against
7850
- `610.802520`, `1127.735817` against `1127.735818`. So the editor is the evidence
7851
- for *what* is computed, and only `PathConstraint` itself is evidence for *how*.
7902
+ reproduce both exports exactly, and since issue #716 below what rigc's own file
7903
+ holds too, because rigc now writes each number as its float32. Under the six fixed
7904
+ decimals it wrote until then the file could tell them apart: on both rigs above
7905
+ they rounded apart on the **last** curve, where the running total has accumulated
7906
+ most — `610.802519` against `610.802520`, `1127.735817` against `1127.735818`. So
7907
+ the editor is the evidence for *what* is computed, and only `PathConstraint`
7908
+ itself is evidence for *how* — `PS67`/`PS68` compare the transcription with it at
7909
+ double precision.
7852
7910
 
7853
7911
  ⭐ **rigc emits the forward difference itself** since
7854
7912
  [#560](https://github.com/firejune/rigc/issues/560) — `pathCurveLengths` in
@@ -7989,6 +8047,7 @@ Per part:
7989
8047
  | --- | --- |
7990
8048
  | `part`, `path`, `width`, `height` | the PNG, by the name every message uses |
7991
8049
  | `placement` | the best placement found — `null` **only** for `empty-part` and `larger-than-canvas`, where nothing was searched |
8050
+ | `walls` | the walls of the search window `placement` stands **on**, whatever the verdict: `[]` when it settled inside, otherwise one `{ axis, edge, window }` per axis — `axis` is `scale` or `rotation`, `edge` is `floor` or `ceiling`, `window` is the flag's own `min,max`. A value on a wall is where the search was held, not where it came to rest — see §11.4 |
7992
8051
  | `alternates` | other optima worth reporting, best first. Non-empty means the answer was not unique |
7993
8052
  | `ambiguous` | at least one alternate is inside the ambiguity margin. **Choose with something this instrument cannot see** — anatomy, the other frame, or `rigc vote` |
7994
8053
  | `rotationFree` | the part is self-similar under rotation, so `rotationDeg` is a placeholder and the value is yours |
@@ -8043,9 +8102,22 @@ holds nothing because it already contains every angle.
8043
8102
  That is the case where the window is the first thing to move rather than the
8044
8103
  frame or the threshold — eleven parts of one frame came back refused at
8045
8104
  `scale=0.500` against art rendered at `0.311` per part pixel, and the message
8046
- said only that the residual was above `--max-residual`. It is printed on a
8047
- **refusal and nowhere else**: an accepted placement sitting on a wall is a window
8048
- chosen to bracket the answer, which is the flag working.
8105
+ said only that the residual was above `--max-residual`. The sentence — with its
8106
+ *"may lie below"* — is printed on a refusal only.
8107
+ - 🔒 **An accepted placement that stopped on a wall says which wall too**, beside
8108
+ the value it holds on the console line and in `walls`:
8109
+ `PLACE head.png x= 45.5 y= 37.5 rot= 0.0° scale=2.000 (the ceiling of --scale 0.5,2) residual=0.0394 unexplained= 10%`
8110
+ is a part drawn at twice the scale of a frame rendered at 1.15 px/unit, whose
8111
+ truth is **2.30** — outside the window — and whose residual at the ceiling still
8112
+ cleared `--max-residual`. *On* a wall is not *near* one: the refinement is clamped
8113
+ to the window, so a value the window held **is** the bound, while a correct
8114
+ placement a window brackets closely settles strictly inside it and carries no
8115
+ mark. A window with no interior (`min === max`) marks nothing — being at its only
8116
+ value says nothing about the answer. ⚠️ The mark names the wall and not the side
8117
+ the truth is on: over the rendered example corpus, a floor held both truths below
8118
+ the window and parts shrunk into their own region whose truth was inside it. An
8119
+ empty `walls` is not evidence either way — a window that excludes the truth can
8120
+ still settle inside on another optimum, which is the caveat two bullets up.
8049
8121
  - ⚠️ **A frame whose border has no dominant colour reports `background.unknown`.**
8050
8122
  Every pixel then counts as material, the silhouette signal is gone, and the
8051
8123
  residual is colour agreement alone. The report says so rather than being quietly
@@ -8293,7 +8365,7 @@ simply unused; a name the directory lacks is refused `no-part-image` by name.
8293
8365
  | --- | --- |
8294
8366
  | `--anchor <pose.json>` | use this `rigc pose` report instead of running one. Refused together with `--scale` / `--rotation`, which size the internal pass that then does not happen |
8295
8367
  | `--atlas` | **refused by name.** Every other `--candidate` command takes it, so trying it here is reasonable — but the part art comes from `--images` and the skeleton is all this needs of the candidate, so a flag that silently did nothing would be worse than one that says why |
8296
- | `--hinge <min,max>` | the window each child's local rotation is searched over, in Spine degrees about its setup value. Default `-180,180` — **a full turn, on purpose**: one degree of freedom is cheap enough to sweep exhaustively, and §11.4's warning about a window that does not contain the truth applies here too |
8368
+ | `--hinge <min,max>` | the window each child's local rotation is searched over, in Spine degrees about its setup value. Default `-180,180` — **a full turn, on purpose**: one degree of freedom is cheap enough to sweep exhaustively, and §11.4's warning about a window that does not contain the truth applies here too. The hinge step `3°` is a **ceiling** on the step, not the step: a window it does not divide is divided into whole steps no coarser than it, and `search` states the step that division produced, so `--hinge -20,20` prints `hinge -20°–20° step 2.857° (15 rungs)` and each chain part's note says it was searched `in 2.857° steps`. The full turn divides it exactly, so the default walks the same rungs it always did |
8297
8369
  | `--stretch <ratio>` | also search a uniform bone scale, this ratio either way. Without it, stretch is searched **only where your own animations key a `scale` timeline on that bone** — a rig that never scales a bone is a rig saying that bone does not stretch |
8298
8370
  | `--min-visible <0..1>` | below this visible share a placement is refused `occluded` instead of reported flat (default `0.25`). A reporting threshold, not a pass bar; the placement is still in the JSON. ⚠️ **It is not inert, though**: a bone whose frozen share is under this floor gets one *unmasked* look before its visible set is fixed, so the flag also changes **where parts land** and not only which rows are refused. Two runs at different `--min-visible` are two fits, and their shares are not one column |
8299
8371
  | `--max-residual <0..1>` | as §11, over the visible pixels (default `0.25`) |
package/docs/FACE.md CHANGED
@@ -1187,13 +1187,14 @@ declares it: `"from": -19, "to": 0, "scale": 0.05, "max": 19, "local": true,
1187
1187
  and `to`** — and `to: 0` says nothing more than *the bottom of the range is the
1188
1188
  animation's first frame*. `from`, `max` and the duration are all the ceiling.
1189
1189
 
1190
- 🔸 **And `scale` is chosen for the endpoint.** rigc rounds every number it emits
1191
- to six decimals, so a `scale` that is not exact there moves the top of the dial:
1192
- `1/60` ships as `0.016667`, and a 60° turn then applies at 1.00002 s rather than
1193
- 1 s — the last frame under `loop: false`, the *first* under `loop: true`
1194
- (AUTHORING §3.5.2). `0.05` is exact at six decimals, which is the only reason the
1195
- example can put its endpoint exactly on the duration. Pick a `scale` that is not,
1196
- and land the endpoint inside the duration instead.
1190
+ 🔸 **And `scale` is chosen for the endpoint.** rigc emits every number as its
1191
+ float32's shortest name, so a `scale` the float cannot hold moves the top of the
1192
+ dial: `1/60` ships as `0.016666668`, and a 60° turn then applies at 1.00000008 s
1193
+ rather than 1 s — the last frame under `loop: false`, the *first* under
1194
+ `loop: true` (AUTHORING §3.5.2). `0.05` is its own float's name, so the file states
1195
+ exactly `0.05`, which is the only reason the example can put its endpoint exactly
1196
+ on the duration. Pick a `scale` that is not, and land the endpoint inside the
1197
+ duration instead.
1197
1198
 
1198
1199
  ⚠️ **The ceiling is per mesh, and the face's is not the smallest one on the
1199
1200
  face.** The same run prints one for every depth mesh, and in this example each
package/docs/INGEST.md CHANGED
@@ -762,18 +762,28 @@ State the ambition in the right units, because three different things get called
762
762
  `build(ingest(x))` is byte-identical for a skeleton **rigc** emitted — that is the
763
763
  contract `bun run selftest` gates on every run — and it is not, for a skeleton the
764
764
  editor emitted. Measured over all twelve corpus exports, a field-by-field walk of the
765
- rebuild against its source produces differences of exactly three kinds, in every file:
765
+ rebuild against its source produces differences of exactly two kinds, in every file:
766
766
 
767
767
  | Kind | Example, candidate vs reference | Why |
768
768
  | --- | --- | --- |
769
769
  | **header bookkeeping**, 3 per file | `skeleton.hash: undefined vs "VFWbaK2UoCM"`, `skeleton.audio: undefined vs null`, `skeleton.spine: "4.3.13" vs "4.3.75-beta"` | the rig spec has no field for `hash` or `audio`, and the version is the runtime rigc links. `ingest` reports all three as findings — `HEADER_BOOKKEEPING` and `HEADER_REDERIVED` |
770
770
  | **an omitted default written out** | `…rotate[0].time: 0 vs undefined` | the editor omits a zero `time`; rigc writes it. AUTHORING §10.5's *do not imitate the exporter's omissions*, from the other side. The header has one of these too and it is the one `ingest` now names: a stage at the origin is written `width`/`height` with no `x`/`y`, and the rebuild spells both — `LOSS HEADER_ORIGIN`, with the box unchanged ([#622](https://github.com/firejune/rigc/issues/622)). No file in this corpus takes that branch: all twelve declare an origin away from 0 |
771
- | **the emitted precision** | `…curve[0]: 0.066667 vs 0.06666667`, `uvs[0]: 0 vs 2.554152e-7` | rigc emits six decimals |
771
+
772
+ 🔢 **A third row stood here until issue #716: the emitted precision** —
773
+ `…curve[0]: 0.066667 vs 0.06666667`, `uvs[0]: 0 vs 2.554152e-7` — because rigc wrote
774
+ six fixed decimals where the editor writes each number as the shortest decimal naming
775
+ its float32. It rewrote every number with more digits than that, by up to 7e-7, with
776
+ no `LOSS` line, and the `uvs[0]` case was that rounding taking a carried value to 0 —
777
+ `ingest` carries every number as the double it parsed, so no derivation was involved.
778
+ rigc now writes the editor's text, and `IG73` holds every number of the twelve
779
+ rebuilds to its source's spelling; `IG75` counts what still differs by kind, and none
780
+ of it is a number. The whitespace of an export is not compared at all — it is an
781
+ export setting, pretty-printed in the examples and one line from the command line.
772
782
 
773
783
  ⇒ **So the corpus gate is `diff` at 1.000 rather than a byte comparison**, and it is
774
784
  worth being exact about what that does and does not cover. `diff` compares structure —
775
785
  counts, names, parentage, order, timeline kinds, key counts, curve kinds — and **not
776
- the values inside the keys**, which is why the precision row above is invisible to it.
786
+ the values inside the keys**, which is why a moved value is invisible to it.
777
787
  On rigc's own rigs byte identity covers both; on a foreign export it used to be
778
788
  `check` (pixels) or nothing, depending on what you render.
779
789
 
@@ -784,8 +794,9 @@ every bone's `length` or mirrored every vertex would read 1.000 on all 49 measur
784
794
  on every `(reported)` one. So the corpus round trip also compares **value by value**,
785
795
  with the format's defaults taken from the parser rather than from a table — both files
786
796
  are read through `spine-core` and the parsed forms are compared path by path, under a
787
- tolerance that is the sum of rigc's own 1e-6 emitted grid and one float32 step of the
788
- runtime's storage. Nine measures, printed on `IG16`'s own line and gated there — here
797
+ tolerance that is the sum of the 1e-6 grid rigc's closed-form models are evaluated on
798
+ — the one absolute grid it still emits on — and one float32 step of the runtime's
799
+ storage. Nine measures, printed on `IG16`'s own line and gated there — here
789
800
  is the `6-arcs` export's, wrapped to fit this page:
790
801
 
791
802
  ```
@@ -805,7 +816,7 @@ does **not** cover, in the same breath:
805
816
  | `version` and `hash` | the rig spec has no field for either, and `ingest` reports both as findings — the header row above, unchanged |
806
817
  | anything below one float32 step | the parser stores frames, curves and vertices in a `Float32Array`, so a difference it cannot represent is invisible to any reading of the parsed form |
807
818
  | a Bezier's handles *as written* | the parser samples them into the curve, so a moved handle arrives as moved samples rather than as the handle it was |
808
- | how the file is **spelled** | field order, an omitted default written out, six decimals against eight — the second and third rows of the table above are values that agree, and this measure says so |
819
+ | how the file is **spelled** | field order and an omitted default written out — the second row of the table above is values that agree, and this measure says so. A number's spelling is `IG73`'s, which reads the rebuild as text |
809
820
  | how it **looks** | that is `check`, and `--texture-from` is how its figure is attributed |
810
821
 
811
822
  The geometric row needs a real number, because a naive reading of `check` makes an
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.33.1",
3
+ "version": "0.34.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {