spine-rigc 1.3.0 → 1.5.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/README.md CHANGED
@@ -535,7 +535,7 @@ commands take it and what its default is.
535
535
  | `validate <dir>` | re-gates artifacts already on disk |
536
536
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
537
537
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
538
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference |
538
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid |
539
539
  | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
540
540
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
541
541
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/cli.ts CHANGED
@@ -103,7 +103,6 @@ import {
103
103
  DEFAULT_PADDING,
104
104
  DEFAULT_PAGE_EDGES,
105
105
  DEFAULT_PAGE_SIZE,
106
- FREE_EDGE_STEP,
107
106
  PAGE_EDGES,
108
107
  packAtlas,
109
108
  type PageEdges,
@@ -145,6 +144,10 @@ import {
145
144
  FRAMES_SIDECAR,
146
145
  FRAMES_SPEC,
147
146
  framingViewport,
147
+ GEOMETRY_FILE,
148
+ GeometryError,
149
+ geometryFileOf,
150
+ geometryText,
148
151
  loadPosable,
149
152
  PROTOCOL_FPS,
150
153
  renderFrame,
@@ -153,6 +156,7 @@ import {
153
156
  SETUP_POSE_DIR,
154
157
  SHEET_FILE,
155
158
  SHEET_TILE,
159
+ sidecarViewport,
156
160
  SlotSubsetError,
157
161
  slotSubsetOf,
158
162
  type Frame,
@@ -259,7 +263,7 @@ function repositoryUrl(): string {
259
263
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
260
264
  * halves together by reading `--help` rather than by naming a flag.
261
265
  */
262
- const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy']);
266
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry']);
263
267
 
264
268
  /**
265
269
  * The flags a command is allowed to spell more than once.
@@ -2010,6 +2014,24 @@ function cmdRender(flags: Record<string, string>): void {
2010
2014
  const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
2011
2015
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
2012
2016
  const outRoot = resolve(flags.out ?? 'render');
2017
+ const geometry = flags.geometry !== undefined;
2018
+ // Refused together rather than one of them ignored (issue #864). The export
2019
+ // records every slot's whole geometry, because a subset is a statement about
2020
+ // which pixels are DRAWN and the pose is the same whatever a picture leaves
2021
+ // out — so a geometry.json beside a subset's frames would describe slots
2022
+ // those frames do not draw, and one that dropped them would stop being the
2023
+ // pose. The whole-rig run writes the same file either way.
2024
+ if (geometry) {
2025
+ for (const flag of ['slot', 'hide'] as const) {
2026
+ if (flags[flag] !== undefined) {
2027
+ throw new UsageError(
2028
+ `--geometry takes no --${flag}: it records every slot's whole geometry, which a subset of the drawn slots ` +
2029
+ `does not change. Run \`rigc render --geometry\` on the whole rig for the file, and --${flag} ` +
2030
+ `${flags[flag]} without --geometry for the pictures — both land on the same grid`,
2031
+ );
2032
+ }
2033
+ }
2034
+ }
2013
2035
 
2014
2036
  console.log('rigc render');
2015
2037
  console.log(` .. skeleton ${skeletonPath}`);
@@ -2031,6 +2053,9 @@ function cmdRender(flags: Record<string, string>): void {
2031
2053
  if (skin !== undefined) console.log(` .. skin ${skin}`);
2032
2054
  if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
2033
2055
 
2056
+ // `null` is a skeleton that posed no vertex at all. One that posed a vertex
2057
+ // it cannot frame — Infinity or NaN — is thrown from the framing as a
2058
+ // `GeometryError` naming the number (issue #873), and never reaches this.
2034
2059
  const viewport = framingViewport(data, maxSide, pose);
2035
2060
  if (!viewport) {
2036
2061
  throw new UsageError(
@@ -2043,8 +2068,24 @@ function cmdRender(flags: Record<string, string>): void {
2043
2068
  // `sampleAll` covers the skeleton with no animation at all, which files its one
2044
2069
  // setup-pose frame under the reserved name. Narrowing to one animation reuses
2045
2070
  // the same sampler rather than a second path through it.
2071
+ //
2072
+ // `--geometry` rides on the SAME call (issue #864): the bones and the whole
2073
+ // attachments are read off the skeleton at the step that drew each frame, so
2074
+ // the export's grid is this frame set's by construction.
2075
+ const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
2046
2076
  const sampled: Map<string, Frame[]> =
2047
- only === undefined ? sampleAll(data, fps, pose) : new Map([[only, sampleAnimation(data, only, fps, pose)]]);
2077
+ only === undefined
2078
+ ? sampleAll(data, fps, sampling)
2079
+ : new Map([[only, sampleAnimation(data, only, fps, sampling)]]);
2080
+ // Every file's text before the first write, so a refused number leaves the
2081
+ // output directory as it was rather than half of a frame set behind it.
2082
+ const geometryTexts = new Map<string, string>();
2083
+ if (geometry) {
2084
+ for (const [name, frames] of sampled) {
2085
+ const animation = name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name;
2086
+ geometryTexts.set(name, geometryText(geometryFileOf(data, animation, fps, frames, viewport, skin)));
2087
+ }
2088
+ }
2048
2089
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
2049
2090
  if (!declaresSetupStage(data)) console.log(` .. ${STAGELESS_FRAMING.render}`);
2050
2091
 
@@ -2067,6 +2108,8 @@ function cmdRender(flags: Record<string, string>): void {
2067
2108
  // would be the same picture with a border and a "0" on it.
2068
2109
  const sheet = frames.length > 1;
2069
2110
  if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
2111
+ const geometryOut = geometryTexts.get(name);
2112
+ if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
2070
2113
  const duration = frames[frames.length - 1].time;
2071
2114
  sets.push({
2072
2115
  dir: dirName,
@@ -2078,7 +2121,8 @@ function cmdRender(flags: Record<string, string>): void {
2078
2121
  duration,
2079
2122
  });
2080
2123
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
2081
- console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${sheet ? ` + ${SHEET_FILE}` : ''} -> ${dir}`);
2124
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}`;
2125
+ console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
2082
2126
  }
2083
2127
 
2084
2128
  // The sidecar is what makes this a frame SET rather than a pile of pictures:
@@ -2095,15 +2139,9 @@ function cmdRender(flags: Record<string, string>): void {
2095
2139
  // every slot says nothing and stays the bytes it always was (issue #835).
2096
2140
  ...subsetFields(subset),
2097
2141
  background: BACKGROUND,
2098
- viewport: {
2099
- x: viewport.minX,
2100
- y: viewport.minY,
2101
- width: viewport.maxX - viewport.minX,
2102
- height: viewport.maxY - viewport.minY,
2103
- scale: viewport.scale,
2104
- pixelWidth: viewport.width,
2105
- pixelHeight: viewport.height,
2106
- },
2142
+ // The one spelling `geometry.json` repeats, so the two files cannot state
2143
+ // two boxes for one grid.
2144
+ viewport: sidecarViewport(viewport),
2107
2145
  sets: [...sets].sort((a, b) => a.dir.localeCompare(b.dir)),
2108
2146
  };
2109
2147
  writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
@@ -3789,7 +3827,7 @@ const FLAG_MEANINGS: Record<string, string> = {
3789
3827
  padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
3790
3828
  "extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
3791
3829
  'page-edges': `what a page's edges may be, --pack only (default ${DEFAULT_PAGE_EDGES}): pot is a power of two on ` +
3792
- `both; free tries every width on a ${FREE_EDGE_STEP}px step, takes the height the placement needs and keeps ` +
3830
+ 'both; free tries every width from the widest part up, takes the height the placement needs and keeps ' +
3793
3831
  'the least area — a smaller page, at the cost of region attachments sampling within 1 LSB of the loose ' +
3794
3832
  'build rather than exactly',
3795
3833
  'atlas-in':
@@ -3808,6 +3846,10 @@ const FLAG_MEANINGS: Record<string, string> = {
3808
3846
  'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
3809
3847
  'as a measurement',
3810
3848
  'all-bones': 'print every bone pair, not just the worst by position',
3849
+ geometry:
3850
+ `also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
3851
+ "slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
3852
+ 'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
3811
3853
  'texture-from':
3812
3854
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
3813
3855
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -4148,10 +4190,10 @@ const COMMANDS: CommandDoc[] = [
4148
4190
  {
4149
4191
  name: 'render',
4150
4192
  usage: [
4151
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--out render/]',
4193
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--out render/]',
4152
4194
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
4153
4195
  ],
4154
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'out'],
4196
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'out'],
4155
4197
  overrides: {
4156
4198
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
4157
4199
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -4499,6 +4541,14 @@ try {
4499
4541
  // the one reader's and already names the file, what it is and what rigc
4500
4542
  // reads; a stack under it is the tool describing its own internals instead.
4501
4543
  // Exit 1, like a compile error: the invocation was fine, a file was not.
4544
+ // A pose that is not finite (issues #864, #873): the invocation was fine and
4545
+ // the skeleton posed a NaN or an infinity, so exit 1 like a file that is not
4546
+ // a PNG. Raised by the geometry export and by the framing — the one sentence
4547
+ // naming the bone or vertex and its value — before the first file is written.
4548
+ if (err instanceof GeometryError) {
4549
+ console.error(`rigc render: ${err.message}`);
4550
+ process.exit(1);
4551
+ }
4502
4552
  if (err instanceof NotAPngError) {
4503
4553
  console.error(`rigc: ${err.message}`);
4504
4554
  process.exit(1);
package/docs/AUTHORING.md CHANGED
@@ -209,6 +209,7 @@ What the flags mean:
209
209
  | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 49, opt-in |
210
210
  | `--candidate` | `check`, `bench`, `render`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. `--atlas <path>` names the atlas when it does not sit beside the skeleton. **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
211
211
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
212
+ | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
212
213
  | `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
213
214
  | `--ballot` | `vote --record` only: the ballot the vote answers (default `ballot.html`). Its embedded manifest is what the vote is checked against, so the ballot file is the record of the question |
214
215
  | `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
@@ -318,6 +319,34 @@ a gap:
318
319
  regions `rotate: 90` lifted every region back byte for byte and drew its
319
320
  region attachments with 0 differing pixels — and made neither page smaller,
320
321
  because both were already on the smallest power-of-two page their area allows.
322
+ Measured again on twelve region sets (issue #866: those two rigs and ten
323
+ production rigs of 20 to 22 parts), packing each with the same search twice —
324
+ as shipped, and with every part also tried turned under the same placement
325
+ score:
326
+
327
+ | Set | `pot` page | turned | `free` page | turned |
328
+ | --- | --- | --- | --- | --- |
329
+ | 22-part painting | 1024x2048 | 0.00 % | 1888x697 | −1.14 % |
330
+ | 20-part painting | 512x2048 | 0.00 % | 480x1166 | +2.31 % |
331
+ | P1 | 512x2048 | 0.00 % | 416x1633 | +1.43 % |
332
+ | P2 | 1024x1024 | 0.00 % | 1184x810 | +0.70 % |
333
+ | P3 | 512x1024 | 0.00 % | 480x1000 | +2.96 % |
334
+ | P4 | 1024x2048 | 0.00 % | 864x1346 | −6.16 % |
335
+ | P5 | 2048x2048 | 0.00 % | 1344x1768 | −13.37 % |
336
+ | P6 | 1024x2048 | 0.00 % | 800x1934 | −1.62 % |
337
+ | P7 | 512x2048 | 0.00 % | 1024x928 | −0.05 % |
338
+ | P8 | 512x2048 | 0.00 % | 608x1250 | −4.96 % |
339
+ | P9 | 1024x2048 | +100.00 % | 1024x2037 | +6.51 % |
340
+ | P10 | 512x2048 | 0.00 % | 352x1433 | +0.70 % |
341
+
342
+ Under `pot` turning shrank no page. Under `free` the largest gain, 13.37 %, is
343
+ inside the search's own noise: the neighbouring width on the 32-pixel grid
344
+ `free` then searched moves that set's unturned page by 18.13 %, and trying
345
+ every width instead finds an unturned page 10.22 % smaller. With every width
346
+ tried, no set gains more than 5.05 % from turning — so the packer still never
347
+ turns a region. (The `free` column is that day's 32-pixel grid. `free` has
348
+ tried every width since issue #872 — below — so the 5.05 % is the turn gain on
349
+ the search that ships.)
321
350
  - **no re-ordering of anything the skeleton says.** `skeleton.json` from a packed
322
351
  build is **byte-identical** to the unpacked one, because sizes are still
323
352
  measured from the loose PNGs and packing is an output arrangement.
@@ -357,7 +386,7 @@ every power-of-two pair up to `--page-size` in order of area and writes the firs
357
386
  the pack fits — it never doubles past a smaller page that would do. So when the
358
387
  parts' own area already exceeds the next power-of-two page down, no `pot` packer
359
388
  can do better, and `free` is the lever left. Under `free` the candidate widths
360
- are the multiples of 32 from the widest padded part up to `--page-size`; at each
389
+ are **every width** from the widest padded part up to `--page-size`; at each
361
390
  width the parts are placed by the same MaxRects pass on a page `--page-size`
362
391
  tall, and the height is the bottom edge of the lowest part. The page with the
363
392
  least area wins, then the squarer one (smaller |width − height|), then the
@@ -365,13 +394,65 @@ narrower one — every candidate has a different width, so the order is total an
365
394
  two builds of the same parts write the same bytes. Spilling, the packing order
366
395
  and `--page-size` as the ceiling are the same as under `pot`.
367
396
 
397
+ Every width is affordable because of two bounds, and neither can change the
398
+ page: a width is not tried when the parts' area cannot fit it or when its
399
+ tallest part alone already makes it larger than the best page so far, and a
400
+ placement stops as soon as its lowest part makes it larger than that best page.
401
+ Both are strict, so a width that could tie the best is always placed to the end.
402
+
403
+ **Why every width, measured (issue #872).** Until then the widths were the
404
+ multiples of 32. On twelve region sets — the two painting rigs below and ten
405
+ production rigs of 20 to 22 parts, the sets of the rotation table above — and,
406
+ as a held-out check, on the ten atlases of the fetched `examples/` corpus, each
407
+ search was run against the every-width page (padding 2, `--page-size` 2048).
408
+ Area change against the 32-pixel grid:
409
+
410
+ | Set | 32 px (before) | step 8 | step 4 | coarse 32 → ±31 px of the best 2 | coarse 12 → ±11 px of the best | every width (now) |
411
+ | --- | --- | --- | --- | --- | --- | --- |
412
+ | 22-part painting | 1888x697 | −1.58 % | −1.58 % | −1.68 % | −1.68 % | 967x1338, −1.68 % |
413
+ | 20-part painting | 480x1166 | 0.00 % | 0.00 % | −0.21 % | −0.21 % | 479x1166, −0.21 % |
414
+ | P1 | 416x1633 | −1.92 % | −2.88 % | −2.88 % | −2.88 % | 404x1633, −2.88 % |
415
+ | P2 | 1184x810 | 0.00 % | 0.00 % | −0.08 % | −0.08 % | 1183x810, −0.08 % |
416
+ | P3 | 480x1000 | 0.00 % | 0.00 % | 0.00 % | 0.00 % | 480x1000, 0.00 % |
417
+ | P4 | 864x1346 | −3.07 % | −3.07 % | −2.66 % | −3.15 % | 1231x915, −3.15 % |
418
+ | P5 | 1344x1768 | −9.90 % | −10.16 % | −10.22 % | −10.22 % | 1427x1495, −10.22 % |
419
+ | P6 | 800x1934 | −1.00 % | −1.50 % | −1.50 % | −1.50 % | 788x1934, −1.50 % |
420
+ | P7 | 1024x928 | −0.78 % | −0.78 % | −0.88 % | −0.88 % | 1015x928, −0.88 % |
421
+ | P8 | 608x1250 | 0.00 % | 0.00 % | −0.16 % | −0.16 % | 607x1250, −0.16 % |
422
+ | P9 | 1024x2037 | −2.34 % | −2.73 % | −2.93 % | −2.93 % | 994x2037, −2.93 % |
423
+ | P10 | 352x1433 | 0.00 % | −1.14 % | −1.70 % | −1.70 % | 346x1433, −1.70 % |
424
+ | worst miss of the every-width page, twelve sets | 11.38 % | 1.73 % | 0.58 % | 0.50 % | 0.00 % | 0.00 % |
425
+ | worst miss, the ten `examples/` atlases | 4.12 % | 2.67 % | 0.77 % | 0.82 % | 0.82 % | 0.00 % |
426
+ | cell placements, twelve sets | 12,361 | 49,063 | 98,021 | 40,230 | 38,371 | 47,978 |
427
+
428
+ The rule was the cheapest search that lands on the every-width page. Flipping
429
+ the packer's own last tie-break (leftmost before topmost) changes no page on any
430
+ of the 22 sets, so there is no tie-break noise to land "within": the target is
431
+ the page itself. The coarse-to-fine search that met it on all twelve sets missed
432
+ it on the first held-out atlas, and its failures do not follow its grid — on
433
+ the twelve sets, refining around the best width of a 9-pixel grid misses where
434
+ 10 to 12 do not, and a 14-pixel grid misses again —
435
+ because the area as a function of the width has minima one pixel wide (a 4-pixel
436
+ grid refined around its best four widths still misses one). Every width is exact
437
+ by construction. Its placement count overstates its cost, because an abandoned
438
+ placement stops early: all twelve sets take 53 ms in the measuring harness,
439
+ against 22 ms for the 32-pixel grid and 810 ms for every width unbounded, and a
440
+ whole `build --pack --page-edges free` of the 22-part rig takes the same wall
441
+ time either way. ⚠️ The cost grows with the part count faster than the grid's:
442
+ on generated sets of 100 and 300 small parts, where no bound prunes much, the
443
+ search places 15 times the grid's cells.
444
+
368
445
  The default stays `pot` because every runtime accepts a power-of-two page and
369
446
  the editor's own packer writes one by default: 9 of the 10 atlases in the
370
447
  fetched `examples/` corpus are power-of-two on both edges. What `free` costs is
371
448
  the exactness above. `x / pageWidth` is no longer exact, so a **region**
372
449
  attachment's samples join a mesh's at one least significant bit against the
373
450
  loose build instead of 0. No region's bytes change: every one lifts back byte for
374
- byte, as under `pot`. Observed on two painting rigs of 22 and 20 parts:
451
+ byte, as under `pot`. Observed on two painting rigs of 22 and 20 parts, on the
452
+ 32-pixel grid `free` shipped with (issue #860); every width now packs them at
453
+ 967x1338 = 1,293,846 (−38.3 %, 91.2 % covered, 59.7 % opaque) and 479x1166 =
454
+ 558,514 (−46.7 %, 93.4 % covered, 53.9 % opaque), and the render columns are
455
+ held to their bound of one by `PK72` on every build rather than re-measured here:
375
456
 
376
457
  | Rig | `pot` page | `free` page | Area | Covered | Opaque (alpha > 0) | Render vs loose, region attachments only | Render vs loose, whole rig |
377
458
  | --- | --- | --- | --- | --- | --- | --- | --- |
@@ -827,6 +908,7 @@ bun cli.ts check --candidate path/to/spine --frames path/to/frames [--skin
827
908
  bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
828
909
  bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
829
910
  bun cli.ts render --candidate path/to/spine --slot <name,…> | --hide <name,…> # part of the rig, same grid
911
+ bun cli.ts render --candidate path/to/spine --geometry # + geometry.json per set: the numbers the frames were drawn from
830
912
  bun cli.ts preview --candidate path/to/spine [--candidate path/to/another …] [--animation …] [--out preview.html]
831
913
  bun cli.ts vote --candidate path/to/a --candidate path/to/b [--out ballot.html]
832
914
  bun cli.ts vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl]
@@ -921,7 +1003,11 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
921
1003
  in draw order; a slot whose art lives only under another skin is refused naming
922
1004
  that skin (pass `--skin`); the two flags together are refused. `frames.json`
923
1005
  records the subset as `slots` or `hidden`, and **`check` refuses such a set as
924
- a reference** (§9). `preview`
1006
+ a reference** (§9). `--geometry` writes the numbers each frame was drawn from
1007
+ beside it — every bone's world transform and every attachment's world vertices
1008
+ after skinning, with their rest geometry — for a judgement the pixels cannot
1009
+ make: a mesh triangle's stretch, a region read in its own bone's frame (§8.2).
1010
+ `preview`
925
1011
  writes one self-contained `.html`: your skeleton, atlas and page PNGs are
926
1012
  embedded in it as data URIs and played by the official **Spine Web Player**, so
927
1013
  double-clicking it is also the interop proof — what plays there was played by
@@ -1412,6 +1498,20 @@ renderers and reports its step 5 as a **SKIP** naming that, and the round trip
1412
1498
  comes back green on the strength of `validate` and `diff`. A skin only **one** side
1413
1499
  can draw is the other case entirely, and stays red.
1414
1500
 
1501
+ 🔢 **"Nothing to draw" is said only of a skeleton that posed no vertex at all.** One
1502
+ whose attachment is there but posed to `Infinity` or `NaN` is refused by the bone or
1503
+ the vertex that holds it and the value — `rigc render: the setup pose: bone "head" has
1504
+ worldX Infinity; a world transform is finite`, or `… animation "turn" frame 31 at 60
1505
+ fps (t=0.5167s): bone "head" has a NaN; …` for a key, or `… slot "x" attachment "y"
1506
+ vertex 3 has x NaN; a posed vertex is finite` — exit 1, before anything is written.
1507
+ It is the sentence `render --geometry` refuses on (§8.2), from the same code, and
1508
+ `rigc check` prints it after `the candidate is posed to a number that is not finite,
1509
+ so no frame of it can be compared:`. The bone is named before its vertices because
1510
+ it is the cause; look for its value in the skeleton file — a setup `x`/`y`/`rotation`,
1511
+ or a timeline key at the time given — and, for a rig spec you wrote, in `rigc explain`,
1512
+ which prints every bone's local transform. A frame's time is the framing pass's own
1513
+ (every animation at 60 fps); the key that overflowed is at or just before it.
1514
+
1415
1515
  **Region attachment** ([Spine: region attachments](http://esotericsoftware.com/spine-regions)),
1416
1516
  the default `type`:
1417
1517
 
@@ -5328,6 +5428,13 @@ rather than linked, like the references at the top of this page: that page cites
5328
5428
  corpus inventory, and a ladder run reads this guide without following its references
5329
5429
  out of it.)
5330
5430
 
5431
+ 🔢 **A fourth surface is a pose that is not finite**, which `render` and `check`
5432
+ refuse before drawing anything, naming the bone or vertex and its value. `validate`
5433
+ does not always get there first: a bone `rotation` of `1e309`, in the setup pose or
5434
+ in a key, was measured leaving `A10_NO_NAN_AFTER_STEPPING` green, because the bone's
5435
+ world position stays finite while its matrix is `NaN`. The sentence, and where to
5436
+ look for the number, is under *nothing to draw* in §3.4.
5437
+
5331
5438
  ### 5.1 Compile errors — before the gate
5332
5439
 
5333
5440
  A `CompileError` names the object and the field, and nothing is written.
@@ -6262,6 +6369,124 @@ the loop with **§9**: the fit's own number says how near this pose is to this f
6262
6369
  and only `check` says whether the shot is the shot. **§9.3** is the list of what even
6263
6370
  that cannot see.
6264
6371
 
6372
+ ### 8.2 The numbers the frames were drawn from — `render --geometry`
6373
+
6374
+ A frame set is pixels, and some judgements about a rig are not about pixels: how far
6375
+ a mesh triangle is stretched over its rest shape, or whether a region holds still in
6376
+ its own bone's frame. Both need the posed numbers, and both need them **on the frames'
6377
+ own grid** — a second tool re-sampling the animation would step the runtime its own
6378
+ way and drift from the pictures it is meant to explain. So `render --geometry` writes
6379
+ them beside the pictures, off the very frames it drew:
6380
+
6381
+ ```bash
6382
+ bun cli.ts render --candidate path/to/spine --geometry [--animation …] [--skin …] [--fps 12]
6383
+ ```
6384
+
6385
+ Each frame directory gains a `geometry.json` beside its `f####.png`. Nothing else
6386
+ changes: the PNGs, `contact.png` and `frames.json` are byte for byte what the same
6387
+ command writes without the flag. The export costs a small fraction of the render —
6388
+ measured on `spineboy-pro` at 12 fps, 190 frames: about 30 ms for the eleven files
6389
+ against about 610 ms for the rasterising alone — which is why there is no
6390
+ geometry-only mode: a consumer judging frames wants the frames, and skipping them
6391
+ would save little.
6392
+
6393
+ ⛔ **Not with `--slot`/`--hide`.** The two are refused together by name, exit 2,
6394
+ nothing written. A subset is a statement about which pixels are *drawn*; the
6395
+ geometry is the whole pose whatever a picture leaves out, so beside a subset's frames
6396
+ it would describe slots those frames do not draw. The whole-rig run writes the same
6397
+ file either way.
6398
+
6399
+ **The file, field by field.** `spec` is `rigc-geometry/1`. The header fields sit one
6400
+ per line and each `rest` entry and each frame on a line of its own, so a diff of two
6401
+ exports names the frame that moved. Two exports of one skeleton are byte-identical.
6402
+
6403
+ | Field | What it holds |
6404
+ | --- | --- |
6405
+ | `coordinates` | the string `spine world, y up, world units` — every position below is in the skeleton's world, not frame pixels |
6406
+ | `animation` | the animation, or `null` for a skeleton with none (its one frame is the setup pose) |
6407
+ | `skin` | the skin these frames were posed under — absent when none was set, exactly as in `frames.json` |
6408
+ | `fps` | the rate the frames were sampled at |
6409
+ | `viewport` | `frames.json`'s own `viewport` object, the same numbers: world box `x`, `y`, `width`, `height`, `scale` (frame pixels per world unit), `pixelWidth`, `pixelHeight`. World to frame pixel is `px = (x − viewport.x) · scale`, `py = (viewport.y + viewport.height − y) · scale`, which is the projection the renderer used |
6410
+ | `bones` | every bone in declaration order: `{ name, parent }`, `parent` `null` for the root |
6411
+ | `slots` | every slot in declaration order: `{ name, bone }` — the bone the slot hangs from |
6412
+ | `rest` | one entry per `(slot, attachment)` any frame shows, in order of first appearance: `slot`, `attachment`, `kind` (`region` or `mesh`), `vertices` at rest, `triangles` (vertex index triplets — a region's are the runtime's `0 1 2 2 3 0`), and for a mesh `hull` (the number of hull vertices, which are the first ones) and `uvs` (the attachment's own, `u, v` per vertex over the untrimmed drawing, y down) |
6413
+ | `setup` | the setup pose, sampled the way a skeleton with no animation is rendered: `{ bones, attachments }` |
6414
+ | `frames` | one per PNG, in index order: `{ index, time, bones, attachments }`. `index` is the number in `f####.png`, `time` is `index / fps` in seconds |
6415
+
6416
+ Inside `setup` and each frame:
6417
+
6418
+ - `bones` — every bone in declaration order: `{ name, a, b, c, d, worldX, worldY }`,
6419
+ the world transform, so a point `(lx, ly)` in the bone's own frame lands at
6420
+ `(a·lx + b·ly + worldX, c·lx + d·ly + worldY)`. The matrix is complete — rotation,
6421
+ scale and shear all live in `a b c d`.
6422
+ - `attachments` — every slot showing a region or a mesh, in the frame's **draw
6423
+ order**: `{ slot, attachment, vertices, color }`. `vertices` are `x, y` per vertex
6424
+ in world units after skinning and deform — the runtime's own
6425
+ `computeWorldVertices` over the **whole** attachment. A region's four are its
6426
+ corners in the order bottom-left, top-left, top-right, bottom-right; a mesh's are
6427
+ in the attachment's own order, so vertex `i` is the same vertex in every frame and
6428
+ in `rest`. `color` is the slot colour times the attachment colour, straight RGBA
6429
+ `0..1` — the same tint the frame was drawn with.
6430
+
6431
+ What `rest` means, precisely: the **setup pose's bones with the slot's deform
6432
+ empty**, taken for every attachment the file shows — including one the setup pose
6433
+ does not show, which a slot only swaps to later. For an attachment the setup pose
6434
+ does show, its `rest` vertices and its `setup` vertices are the same numbers bit for
6435
+ bit, because they come off one posed skeleton.
6436
+
6437
+ ⚠️ **What is deliberately not in it.** Slots `--slot`/`--hide` would leave out are in
6438
+ it (see above). A clip is not applied: a clipping attachment removes pixels, and the
6439
+ clipper's output is a new triangle list whose vertices are not the attachment's and
6440
+ are not numbered like them — so `vertices` are the attachment's own, and the frames
6441
+ show what the clip left of them. Bounding boxes, points, paths and clipping polygons
6442
+ draw nothing and are not listed. Numbers are printed the way every JSON file this
6443
+ tool writes prints them — the shortest decimal that reads back as the same double —
6444
+ so a vertex in the file *is* the runtime's vertex, not a rounding of it; a pose that
6445
+ is not finite is refused rather than written as `null`, naming where — the setup pose,
6446
+ `animation "a" frame i at F fps (t=…s)`, or the rest table — and then the bone and
6447
+ its world-transform field, or the slot, the attachment and the vertex, with the
6448
+ value. Bones come first, so a bone that overflowed is named as itself rather than as
6449
+ its first vertex. Since issue #873 plain `render` refuses the same pose in the same
6450
+ sentence while framing it (§3.4's note under *nothing to draw*), so the flag is not
6451
+ needed to find out which number it was.
6452
+
6453
+ **The two judgements the file was designed for**, written once so a consumer
6454
+ implements what the file promises:
6455
+
6456
+ 1. **Texture stretch of a mesh triangle.** For each `rest` entry of kind `mesh` and
6457
+ each triangle `(i, j, k)` in its `triangles`, over every frame whose `attachments`
6458
+ carry the same `slot` and `attachment`:
6459
+
6460
+ `stretch = max over frames, max over the edges (i,j) (j,k) (k,i) of |posed edge| / |rest edge|`
6461
+
6462
+ where an edge's length is `hypot(x_j − x_i, y_j − y_i)` over that frame's
6463
+ `vertices` and over `rest`'s. The worst triangle is named by its `slot`, its
6464
+ triangle index and its three vertex indices. A value of exactly `1` is a
6465
+ triangle no frame moved. It is measured against rest, so it counts what the
6466
+ bones do to a weighted mesh as well as what a deform key does — unlike the
6467
+ `DEFORM` block, which compares a key against the same bones with the deform
6468
+ cleared.
6469
+ 2. **A region still in its bone's own frame.** Take the region's `slot`, look its
6470
+ `bone` up in `slots`, and for each frame carry its four corners into that bone's
6471
+ frame by the inverse of the bone's world transform:
6472
+
6473
+ `det = a·d − b·c`, `dx = x − worldX`, `dy = y − worldY`,
6474
+ `lx = (d·dx − b·dy) / det`, `ly = (−c·dx + a·dy) / det`
6475
+
6476
+ Against the same four carried from `setup`, the largest difference over frames is
6477
+ the region's motion *relative to its bone*: zero, to double rounding, for a region
6478
+ nothing but its bone moves. A face region measured against a head bone this way
6479
+ is the face with the head's own turn, translation and scale taken out.
6480
+
6481
+ **Measured on the example corpus** at 12 fps (these are one run's figures, not
6482
+ thresholds): `spineboy-pro`'s eleven files total 5.1 MB, from 72 kB (`aim`, one
6483
+ frame) to 1.46 MB (`death`, 60 frames), and `sack-pro`'s four 1.4 MB. The largest
6484
+ stretch is `sack-pro` `fall-in`, `cape-back` triangle 15, ×6.744; the largest
6485
+ still-in-frame residual over every region of `spineboy-pro` is 4.7e-12 world units
6486
+ (`portal-streaks2`, whose bone scales; `sack-pro` has no region).
6487
+ Topology and UVs sit in `rest` rather than on every frame because they do not
6488
+ change: repeating them per frame would add 35 % to `spineboy-pro`'s files.
6489
+
6265
6490
  ---
6266
6491
 
6267
6492
  ## 9. Checking against the frames — `rigc check`
@@ -8588,7 +8813,7 @@ ambiguous one ends with a sentence saying which reading its figures support:
8588
8813
 
8589
8814
  - **below the floor** — a 40x28 part of one flat colour, cut from a plain block it fits anywhere inside:
8590
8815
 
8591
- > part is 40x28 px (span 40 frame px, opaque 1) with texture 0, detail 0; 11 candidate(s), best 0.0000, next 0.0000, spread 0.0000 — below the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up): pose placed 7% of the measured parts this plain, so this part is too plain for pose to tell its placements apart; that says nothing about whether the cut is right
8816
+ > part is 40x28 px (span 40 frame px, opaque 1) with texture 0, detail 0; 17 candidate(s), best 0.0000, next 0.0000, spread 0.0000 — below the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up): pose placed 7% of the measured parts this plain, so this part is too plain for pose to tell its placements apart; that says nothing about whether the cut is right
8592
8817
 
8593
8818
  - **above the floor** — a 200x150 black-and-white checker the frame does not hold:
8594
8819
 
@@ -8615,24 +8840,24 @@ Found within 2 px of the truth, out of 15 trials per cell (free search, default
8615
8840
 
8616
8841
  | texture level (mean) | 24 px | 32 px | 48 px | 64 px | 96 px | 128 px | 192 px |
8617
8842
  | --- | --- | --- | --- | --- | --- | --- | --- |
8618
- | `native` (detail 3.49) | 10/15 (5 amb) | 8/15 (6 amb, 1 wrong) | 11/15 (4 amb) | 9/15 (4 amb, 2 wrong) | 10/15 (4 amb, 1 wrong) | 10/15 (3 amb, 2 wrong) | 12/15 (3 amb) |
8619
- | `blur1` (detail 1.58) | 9/15 (6 amb) | 9/15 (6 amb) | 10/15 (4 amb, 1 wrong) | 8/15 (5 amb, 2 wrong) | 11/15 (2 amb, 2 wrong) | 9/15 (2 amb, 4 wrong) | 9/15 (5 amb, 1 wrong) |
8620
- | `blur2` (detail 0.59) | 6/15 (9 amb) | 9/15 (4 amb, 2 wrong) | 8/15 (4 amb, 3 wrong) | 7/15 (4 amb, 4 wrong) | 8/15 (2 amb, 5 wrong) | 6/15 (4 amb, 5 wrong) | 6/15 (4 amb, 5 wrong) |
8621
- | `flat` (detail 0.00) | 0/15 (14 amb, 1 wrong) | 0/15 (14 amb, 1 wrong) | 0/15 (12 amb, 3 wrong) | 0/15 (11 amb, 4 wrong) | 0/15 (12 amb, 3 wrong) | 0/15 (12 amb, 3 wrong) | 0/15 (12 amb, 3 wrong) |
8843
+ | `native` (detail 3.49) | 13/15 (2 amb) | 15/15 | 15/15 | 15/15 | 13/15 (1 amb, 1 wrong) | 14/15 (1 wrong) | 14/15 (1 wrong) |
8844
+ | `blur1` (detail 1.58) | 12/15 (3 amb) | 12/15 (2 amb, 1 wrong) | 13/15 (2 amb) | 12/15 (3 amb) | 12/15 (2 amb, 1 wrong) | 11/15 (1 amb, 3 wrong) | 11/15 (2 amb, 2 wrong) |
8845
+ | `blur2` (detail 0.59) | 9/15 (5 amb, 1 wrong) | 9/15 (4 amb, 2 wrong) | 9/15 (4 amb, 2 wrong) | 8/15 (3 amb, 4 wrong) | 7/15 (3 amb, 5 wrong) | 6/15 (4 amb, 5 wrong) | 7/15 (3 amb, 5 wrong) |
8846
+ | `flat` (detail 0.00) | 0/15 (14 amb, 1 wrong) | 0/15 (14 amb, 1 wrong) | 0/15 (13 amb, 2 wrong) | 0/15 (11 amb, 4 wrong) | 0/15 (13 amb, 2 wrong) | 0/15 (12 amb, 3 wrong) | 0/15 (12 amb, 3 wrong) |
8622
8847
 
8623
8848
  The same trials, grouped by `detail`:
8624
8849
 
8625
8850
  | detail | found | ambiguous | wrong | refused |
8626
8851
  | --- | --- | --- | --- | --- |
8627
- | under 0.25 | 8/119 | 87 | 24 | 0 |
8628
- | 0.25–0.5 | 0/3 | 3 | 0 | 0 |
8629
- | 0.5–0.75 | 46/81 | 15 | 20 | 0 |
8630
- | 0.75–1 | 14/34 | 18 | 2 | 0 |
8631
- | 1–1.5 | 19/25 | 3 | 3 | 0 |
8632
- | 1.5–2 | 29/48 | 16 | 3 | 0 |
8633
- | 2–3 | 27/44 | 14 | 3 | 0 |
8634
- | 3–4 | 25/34 | 7 | 2 | 0 |
8635
- | 4 and over | 17/32 | 14 | 1 | 0 |
8852
+ | under 0.25 | 8/119 | 89 | 22 | 0 |
8853
+ | 0.25–0.5 | 1/3 | 1 | 1 | 0 |
8854
+ | 0.5–0.75 | 48/81 | 14 | 19 | 0 |
8855
+ | 0.75–1 | 17/34 | 14 | 3 | 0 |
8856
+ | 1–1.5 | 25/25 | 0 | 0 | 0 |
8857
+ | 1.5–2 | 34/48 | 12 | 2 | 0 |
8858
+ | 2–3 | 40/44 | 1 | 3 | 0 |
8859
+ | 3–4 | 34/34 | 0 | 0 | 0 |
8860
+ | 4 and over | 30/32 | 2 | 0 | 0 |
8636
8861
 
8637
8862
  **Method.** The truth set is `examples/spineboy`: its setup pose and one frame of
8638
8863
  `walk` (a third of the way in). For every trial the figure is rendered twice with one
@@ -8654,25 +8879,63 @@ part has a low per-pixel gradient and still places — and size alone has no
8654
8879
  measurable effect across 24–192 px at any level.
8655
8880
 
8656
8881
  **The edge**, derived by `deriveFloor` as the largest rung of a fixed ladder under
8657
- which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **6.6%**
8658
- of the trials were found (8/122); at or over it, **59.4%** (177/298). A part is below
8882
+ which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **7.4%**
8883
+ of the trials were found (9/122); at or over it, **76.5%** (228/298). A part is below
8659
8884
  the floor when its `detail` is under 0.5, or when its own longest side is under 24 px,
8660
8885
  the smallest size measured (there, nothing measured says `pose` can place it).
8661
8886
 
8662
- ⚠️ **What this is and is not.** No cell of the grid placed 90% of its trials (the best
8663
- placed 12/15), so there is no size and texture above which `pose` is reliable, and
8664
- none is claimed. The failures above the edge are the same few parts at every size
8665
- (the gun placed 10 of 42, the goggles 11 of 42), and they are search failures rather
8666
- than plainness: on one of them the true placement scored a lower residual than the two
8667
- wrong answers the search reported. All this was measured on **ideal cuts**, the frame's
8668
- own pixels, where a residual at the truth is near zero. A generated cut also disagrees
8669
- with its picture, which can only make it harder to place. So the edge is where `pose`
8670
- fails even on ideal cuts, and never a guarantee above it.
8887
+ **What the search carries, and why.** The coarse pass scores the part at anchor
8888
+ cells an **eighth** of its own span apart, and every scale rung sends its **five**
8889
+ best distinct places down to the finer levels. Both were set on this grid (issue
8890
+ #865): of the 65 failures whose truth scored better than every answer reported, 38
8891
+ had a truth that beat its whole scale rung at the coarse level while the anchor cell
8892
+ holding it did not — at a quarter-span stride a thin gun or a gapped fist falls between
8893
+ two anchors — and only 3 had it ranked under the old cutoff of three. The finer grid
8894
+ took the grid from 185 to 227 trials found and those misses from 65 to 14, for about
8895
+ 10–14% more CPU over the grid (615 s of user time before it, 678 s and 704 s on two runs after).
8896
+
8897
+ The refinement then polishes each place down the pyramid, and at full resolution a
8898
+ polish that has stopped tries its scale **up by half a coarse rung** (2^(1/6)), re-fits
8899
+ the position there, and restarts if that scores better; fifteen places survive each
8900
+ level. Both were set on this grid too (issue #877): of the 14 misses left after #865,
8901
+ 12 reached full resolution with the truth's place ranked first or second and the
8902
+ full-resolution objective preferring the truth, and stopped 2–7 px off it at a shrunk
8903
+ scale — the objective couples scale to position along a diagonal, so every
8904
+ one-axis step from there is worse while a joint one is better. None of them had
8905
+ accepted a worse residual. One more was the 24 px shin, its truth's place ranked
8906
+ 15th at the 2x level and cut at twelve. Up only because the objective charges part
8907
+ pixels that land off the figure and nothing for figure left uncovered, so the
8908
+ reduced levels err small: every stuck point sat below scale 1. It took the grid from
8909
+ 227 to 237 found and the misses from 14 to 8 (12 trials gained, 2 lost — each a
8910
+ blurred part now reported with a second placement at the same spot and another
8911
+ scale, one of them with the truth itself as its best answer), for 43% more
8912
+ refinement samples and the coarse pass untouched.
8913
+
8914
+ ⚠️ **What this is and is not.** The grid is not a reliability line: five native cells
8915
+ place 14 or 15 of 15 and the trials at `detail` 3 and over place 64 of 66, but the
8916
+ goggles still place 12 of 42, and a threshold read off two dozen parts of one figure
8917
+ would be a claim about that figure. What the grid can separate is **whose** failure a
8918
+ failure is. `bun tools/pose_floor.ts` prints the residual at the truth for every
8919
+ failed trial and marks it **missed** when the truth scores better than everything
8920
+ reported and nothing reported is on it — a failure the search could fix. The other
8921
+ failures are the objective preferring another placement (a blurred part shrunk into
8922
+ its own region scores better than its truth) or the truth reported and tied, and no
8923
+ search can fix those. After #877, 8 of the 183 failed trials are misses, and 7 of
8924
+ those sit within 3.1 px of the truth rather than on another hill; the `--from`
8925
+ listing prints, for each, where the refinement left the truth level by level. All this was
8926
+ measured on **ideal cuts**, the frame's own pixels, where a residual at the truth is
8927
+ near zero. A generated cut also disagrees with its picture, which can only make it
8928
+ harder to place. So the edge is where `pose` fails even on ideal cuts, and never a
8929
+ guarantee above it.
8671
8930
 
8672
8931
  Re-run it with `bun tools/pose_floor.ts --textures <level> --json <out>` (one level per
8673
8932
  process parallelises it), then `bun tools/pose_floor.ts --from <a.json>,<b.json>,…` to
8674
- print the table and derive the edge. `PO22` in `bun run selftest` re-measures three of
8675
- its cells.
8933
+ print the table, derive the edge and list the misses per cell. `PO22` in
8934
+ `bun run selftest` re-measures three of its cells, `PO24` holds those three cells to
8935
+ no miss at all, and `PO23` places the 48 px fist #865 was made for; `PO28` and
8936
+ `PO29` place the four trials #877 was made for and read where the shin survived the
8937
+ cutoff, and `PO26` holds every polish to strictly descending residuals, read through
8938
+ the same trace.
8676
8939
 
8677
8940
  ## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
8678
8941
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.3.0",
3
+ "version": "1.5.0",
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": {