spine-rigc 1.2.3 → 1.3.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
@@ -530,7 +530,7 @@ commands take it and what its default is.
530
530
  | Command | Does |
531
531
  | --- | --- |
532
532
  | `build --rig … --motion … --out …` | compiles, gates, and **writes only if the gate is green**. `--images <dir>` says where the rig spec's `image` names resolve, `--manifest` adds measured art, and `--copy-images` copies every page PNG into `--out` so the directory is self-contained, with `skeleton.images` pointing at it so the editor's import finds the parts |
533
- | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size` and `--padding` tune it |
533
+ | `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size`, `--padding` and `--page-edges` tune it |
534
534
  | `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
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 |
package/cli.ts CHANGED
@@ -101,8 +101,12 @@ import { NotAPngError } from './src/png.ts';
101
101
  import { copyAtlasPages } from './src/emit.ts';
102
102
  import {
103
103
  DEFAULT_PADDING,
104
+ DEFAULT_PAGE_EDGES,
104
105
  DEFAULT_PAGE_SIZE,
106
+ FREE_EDGE_STEP,
107
+ PAGE_EDGES,
105
108
  packAtlas,
109
+ type PageEdges,
106
110
  pageFootprint,
107
111
  parseAtlasText,
108
112
  type AtlasRegion,
@@ -612,9 +616,37 @@ const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> =
612
616
  ribbon: 'ribbon entry row pinned, rows share their weights so the strip lengthens without widening',
613
617
  contour: 'contour the art\'s own silhouette, every vertex pinned to the slot bone (geometry, not a deformation)',
614
618
  grid: 'grid a lattice over the part window at stated column and row positions, every vertex pinned to the slot bone',
619
+ segments: 'segments a lattice over the part\'s alpha, every vertex weighted by distance to the bone segments it names',
615
620
  authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
616
621
  };
617
622
 
623
+ /**
624
+ * The line under a `segments` mesh: how its bones share the vertices.
625
+ *
626
+ * ⭐ The weights are this generator's whole output, and the per-vertex numbers
627
+ * are nowhere a reader can see them — so the three figures that say whether the
628
+ * falloff did what was meant are printed where the mesh is: the most bones any
629
+ * vertex binds (against `maxBones`), the mean, and how many vertices a single
630
+ * bone owns outright. A named bone no vertex binds is said by name, because it
631
+ * is in no weight and so in no other figure either.
632
+ */
633
+ function meshInfluenceNote(m: CompileResult['meshes'][number]): string {
634
+ const inf = m.influence;
635
+ if (inf === undefined) return '';
636
+ const unbound = m.bones.filter((b) => !inf.bound.includes(b));
637
+ const single = m.vertices === 0 ? 0 : (inf.singleBone / m.vertices) * 100;
638
+ const joined = inf.keptCells - inf.artCells;
639
+ return (
640
+ `\n influence max ${inf.maxBones} bone(s) per vertex, mean ${inf.meanBones.toFixed(2)}, ` +
641
+ `${inf.singleBone} of ${m.vertices} vertices (${single.toFixed(2)}%) on a single bone` +
642
+ (unbound.length ? `; named and bound by no vertex: ${unbound.join(', ')}` : '') +
643
+ `\n lattice cell ${inf.cell}px, ${inf.cols}x${inf.rows} cells, ${inf.artCells} with art, ${inf.keptCells} kept` +
644
+ (inf.islands > 1 || joined > 0
645
+ ? ` (${inf.islands} island(s) joined into one outline, ${joined} cell(s) added that hold no art)`
646
+ : '')
647
+ );
648
+ }
649
+
618
650
  /**
619
651
  * Why a figure taken off a part's texels is withheld on a page whose file is
620
652
  * not the size its atlas declares — the tail of every line that withholds one
@@ -1260,6 +1292,21 @@ function readIntFlag(flags: Record<string, string>, name: string, fallback: numb
1260
1292
  return Number(raw);
1261
1293
  }
1262
1294
 
1295
+ /**
1296
+ * Read `--page-edges`, or its default — `pot`.
1297
+ *
1298
+ * An unknown value is a usage error for `readProfile`'s reason: a typo that fell
1299
+ * back to `pot` would hand the caller who asked for the smaller page the bigger
1300
+ * one, green, and say nothing.
1301
+ */
1302
+ function readPageEdges(flags: Record<string, string>): PageEdges {
1303
+ const raw = flags['page-edges'];
1304
+ if (raw === undefined) return DEFAULT_PAGE_EDGES;
1305
+ const found = PAGE_EDGES.find((e) => e === raw);
1306
+ if (!found) throw new UsageError(`--page-edges ${JSON.stringify(raw)}; known values: ${PAGE_EDGES.join(', ')}`);
1307
+ return found;
1308
+ }
1309
+
1263
1310
  /**
1264
1311
  * One `DROP` line, written once because two outcomes print it.
1265
1312
  *
@@ -1343,7 +1390,7 @@ function cmdBuild(flags: Record<string, string>): void {
1343
1390
  // now a build like any other — and it is the only one that puts the renderer's
1344
1391
  // own rulebook over shared-page sampling.
1345
1392
  if (!packing) {
1346
- for (const name of ['page-size', 'padding'] as const) {
1393
+ for (const name of ['page-size', 'padding', 'page-edges'] as const) {
1347
1394
  if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
1348
1395
  }
1349
1396
  }
@@ -1394,7 +1441,8 @@ function cmdBuild(flags: Record<string, string>): void {
1394
1441
  console.log(
1395
1442
  ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
1396
1443
  `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}` +
1397
- meshDepthNote(m),
1444
+ meshDepthNote(m) +
1445
+ meshInfluenceNote(m),
1398
1446
  );
1399
1447
  }
1400
1448
  for (const ph of result.physics) {
@@ -1445,6 +1493,7 @@ function cmdBuild(flags: Record<string, string>): void {
1445
1493
  const packOpts = {
1446
1494
  pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
1447
1495
  padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
1496
+ pageEdges: readPageEdges(flags),
1448
1497
  pageStem: 'skeleton',
1449
1498
  };
1450
1499
  const inputs = result.images.map((img) => ({
@@ -1460,7 +1509,8 @@ function cmdBuild(flags: Record<string, string>): void {
1460
1509
  console.log(
1461
1510
  ` .. pack: ${page.name} ${page.width}x${page.height}, ` +
1462
1511
  `${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
1463
- `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}`,
1512
+ `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}` +
1513
+ (packOpts.pageEdges === 'free' ? ', page edges free' : ''),
1464
1514
  );
1465
1515
  }
1466
1516
  for (const place of packed.placements) {
@@ -3327,7 +3377,8 @@ function cmdExplain(flags: Record<string, string>): void {
3327
3377
  console.log(
3328
3378
  ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
3329
3379
  `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}` +
3330
- meshDepthNote(m),
3380
+ meshDepthNote(m) +
3381
+ meshInfluenceNote(m),
3331
3382
  );
3332
3383
  }
3333
3384
  }
@@ -3737,6 +3788,10 @@ const FLAG_MEANINGS: Record<string, string> = {
3737
3788
  'one written is the smallest that holds the pack, spilling to more pages only when the set will not fit',
3738
3789
  padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
3739
3790
  "extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
3791
+ '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 ` +
3793
+ 'the least area — a smaller page, at the cost of region attachments sampling within 1 LSB of the loose ' +
3794
+ 'build rather than exactly',
3740
3795
  'atlas-in':
3741
3796
  'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
3742
3797
  'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
@@ -3855,6 +3910,7 @@ const FLAG_VALUES: Record<string, string> = {
3855
3910
  'atlas-in': '<file.atlas>',
3856
3911
  'page-size': '<px>',
3857
3912
  padding: '<px>',
3913
+ 'page-edges': 'pot|free',
3858
3914
  'texture-from': '<path>',
3859
3915
  reference: '<dir|skeleton.json>',
3860
3916
  'reference-atlas': '<path>',
@@ -3941,7 +3997,7 @@ const COMMANDS: CommandDoc[] = [
3941
3997
  name: 'build',
3942
3998
  usage: [
3943
3999
  'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
3944
- `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] (parts onto shared pages, written into --out)`,
4000
+ `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] (parts onto shared pages, written into --out)`,
3945
4001
  'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
3946
4002
  'rigc build --cut <name> --cuts <cuts.json>',
3947
4003
  ],
@@ -3955,6 +4011,7 @@ const COMMANDS: CommandDoc[] = [
3955
4011
  'pack',
3956
4012
  'page-size',
3957
4013
  'padding',
4014
+ 'page-edges',
3958
4015
  'atlas-in',
3959
4016
  'cut',
3960
4017
  'cuts',
@@ -3973,7 +4030,7 @@ const COMMANDS: CommandDoc[] = [
3973
4030
  'this line said "the same arguments as build, minus --profile" and was false in both',
3974
4031
  'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
3975
4032
  'command read what `ingest --art none` writes was reachable and undocumented, while',
3976
- '--pack, --page-size, --padding and --copy-images are build\'s and do nothing here —',
4033
+ '--pack, --page-size, --padding, --page-edges and --copy-images are build\'s and do nothing here —',
3977
4034
  'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
3978
4035
  'above, and that is now the whole of it.',
3979
4036
  ],
package/docs/AUTHORING.md CHANGED
@@ -200,6 +200,7 @@ What the flags mean:
200
200
  | `--pack` | `build` only: arrange every part onto **shared** atlas page(s), written into `--out` as real PNGs, instead of one page per part. Lossless — nothing is resampled, trimmed or rotated. The default is one page per part — **§0.1** |
201
201
  | `--page-size` | `build --pack` only: the largest page edge (default `2048`). A ceiling, not the size: page edges are powers of two and the one written is the smallest that holds the pack — **§0.1** |
202
202
  | `--padding` | `build --pack` only: the gutter each region reserves on every side (default `2`), filled by extending the region's own edge pixels outwards. `0` is not a legal-but-tight choice, it is bleed — **§0.1** |
203
+ | `--page-edges` | `build --pack` only: `pot` (default) or `free`. `pot` keeps both page edges powers of two; `free` sizes the page to the parts — a smaller page, at the cost of region attachments sampling within one least significant bit of the loose build instead of exactly. Any other value is refused by name — **§0.1** |
203
204
  | `--atlas-in` | `build` and `explain`: resolve every part against the **regions of a pre-packed `.atlas`** instead of against loose PNGs. Region geometry is read from the file and sizes are descaled by the page's `scale:`; `build` re-emits the atlas into `--out`, re-anchored, and `explain` writes nothing and poses through it — **§0.2**. On `explain` it is the flag that makes a **size-only** spec readable at all (`ingest --art none`), because posing resolves every attachment against an atlas; without it that pair is refused by name rather than thrown through (§5.1) |
204
205
  | `--images` | where the rig spec's `image` names resolve (overrides the rig's own `images` field, and is relative to your working directory). For `pose` it is the directory of **loose part PNGs to place** — every `.png` in it is a part, in name order. For `chainfit` it is only where each attachment's image name **resolves**: the candidate decides what the parts are, so extra PNGs are unused and a missing name is refused by name (§12.3) |
205
206
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
@@ -313,7 +314,10 @@ a gap:
313
314
  `RegionAttachment.computeUVs` assigns a different corner order at 90, so a
314
315
  rotated pack is one rigc's own `--atlas` substitution cannot read.
315
316
  Rotation buys page area; a page that runs out of room spills to a second page
316
- instead.
317
+ instead. Measured on two painting rigs (issue #860), a pack that turned 12
318
+ regions `rotate: 90` lifted every region back byte for byte and drew its
319
+ region attachments with 0 differing pixels — and made neither page smaller,
320
+ because both were already on the smallest power-of-two page their area allows.
317
321
  - **no re-ordering of anything the skeleton says.** `skeleton.json` from a packed
318
322
  build is **byte-identical** to the unpacked one, because sizes are still
319
323
  measured from the loose PNGs and packing is an output arrangement.
@@ -348,6 +352,38 @@ it usually does not appear at all: eleven of the thirteen rigs in this repositor
348
352
  render **byte-identically** packed and unpacked across 1,101 frames, and the two
349
353
  that do not are the two with meshes, at 1 and 146 samples of 6 and 60 million.
350
354
 
355
+ **`--page-edges free`: a page sized to the parts.** The default, `pot`, tries
356
+ every power-of-two pair up to `--page-size` in order of area and writes the first
357
+ the pack fits — it never doubles past a smaller page that would do. So when the
358
+ parts' own area already exceeds the next power-of-two page down, no `pot` packer
359
+ 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
361
+ width the parts are placed by the same MaxRects pass on a page `--page-size`
362
+ tall, and the height is the bottom edge of the lowest part. The page with the
363
+ least area wins, then the squarer one (smaller |width − height|), then the
364
+ narrower one — every candidate has a different width, so the order is total and
365
+ two builds of the same parts write the same bytes. Spilling, the packing order
366
+ and `--page-size` as the ceiling are the same as under `pot`.
367
+
368
+ The default stays `pot` because every runtime accepts a power-of-two page and
369
+ the editor's own packer writes one by default: 9 of the 10 atlases in the
370
+ fetched `examples/` corpus are power-of-two on both edges. What `free` costs is
371
+ the exactness above. `x / pageWidth` is no longer exact, so a **region**
372
+ attachment's samples join a mesh's at one least significant bit against the
373
+ 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:
375
+
376
+ | Rig | `pot` page | `free` page | Area | Covered | Opaque (alpha > 0) | Render vs loose, region attachments only | Render vs loose, whole rig |
377
+ | --- | --- | --- | --- | --- | --- | --- | --- |
378
+ | 22 parts | 1024x2048 = 2,097,152 | 1888x697 = 1,315,936 | −37.3 % | 56.3 % → 89.7 % | 36.8 % → 58.7 % | 0 → 2,323 px, worst 1 | 3,854 → 5,216 px, worst 1 |
379
+ | 20 parts | 512x2048 = 1,048,576 | 480x1166 = 559,680 | −46.6 % | 49.7 % → 93.2 % | 28.7 % → 53.8 % | 0 → 527 px, worst 1 | 1,260 → 1,669 px, worst 1 |
380
+
381
+ The pixel counts are over 49 frames at `render --max 1024`. **Covered** is the
382
+ regions' own rectangles over the page, which the pack line prints; **opaque** is
383
+ the page's texels with any alpha. The gap between the two is transparency
384
+ inside the parts, which no packer that copies bytes can remove. The pack line
385
+ ends `, page edges free` when the flag is set.
386
+
351
387
  ### 0.2 Building against a pack somebody else made — `--atlas-in`
352
388
 
353
389
  The other direction. `--atlas-in <file.atlas>` resolves every part against the
@@ -814,8 +850,9 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
814
850
  `build --atlas-in` gates green is readable here through the same flag, and
815
851
  without it the pair is refused by name at exit 2 rather than posed
816
852
  (§5.1). ⚠️ `--profile`,
817
- `--pack`, `--page-size`, `--padding` and `--copy-images` are `build`'s and are
818
- not here: four of them decide what is *written*, and this command writes nothing.
853
+ `--pack`, `--page-size`, `--padding`, `--page-edges` and `--copy-images` are
854
+ `build`'s and are not here: five of them decide what is *written*, and this
855
+ command writes nothing.
819
856
 
820
857
  📐 **What it will not measure: the texels of a page that is not its declared
821
858
  size.** Under
@@ -1703,13 +1740,15 @@ is the wrap-around curve from `x = 180` back to `x = 0`, which nothing reads. Th
1703
1740
  §3.5.1's constraint example rides: with `position: 0.25` its `cart` bone poses at
1704
1741
  `worldX = 45.000000`.
1705
1742
 
1706
- The generators are `ring`, `ribbon`, `contour` and `grid` (see
1743
+ The generators are `ring`, `ribbon`, `contour`, `grid` and `segments` (see
1707
1744
  [`src/mesh.ts`](../src/mesh.ts)); the first two encode a deformation model rather
1708
- than a table of numbers, which is why they are code invoked by data. The last two
1709
- are geometry: they pin every vertex to the slot bone and exist to give a
1710
- `deform` timeline (§4.12) somewhere to push. A generator
1711
- is for a skeleton with **no** manifest; a cut that has one invokes the same
1712
- builders through the manifest's `mesh` block.
1745
+ than a table of numbers, which is why they are code invoked by data. `contour`
1746
+ and `grid` are geometry: they pin every vertex to the slot bone and exist to give
1747
+ a `deform` timeline (§4.12) somewhere to push. `segments` is both at once — a
1748
+ lattice traced off the art, weighted by distance to the bones you name. A
1749
+ generator is for a skeleton with **no** manifest; a cut that has one invokes the
1750
+ same builders through the manifest's `mesh` block — except `segments`, which has
1751
+ no manifest spelling and is reached from a rig spec only.
1713
1752
 
1714
1753
  🚨 **A rig that invokes a generator must declare `invariants.meshSlots` (§3.7).**
1715
1754
  Geometry rigc built is geometry rigc will not ship **unmeasured**: a generated
@@ -2256,6 +2295,130 @@ influence through that bone's own inverse, so the **angle** a raised surface tur
2256
2295
  through and the **impact** a soft one answers sit on one slot. ⚠️ Read §4.11.1 before you add a mask to a part that already turns:
2257
2296
  past one bone the model's own coordinates are **world** ones, so `radius` and
2258
2297
  `about` change units.
2298
+ #### `segments` — a lattice over the art, weighted by distance to the bones you name
2299
+
2300
+ **When you need one:** an arbitrary layer that a chosen set of bones should
2301
+ pull — a robe over a chest, a hip and two legs; a head of hair over a skull and
2302
+ three locks; a sleeve under an arm chain. None of the other generators fits
2303
+ that: a `ribbon` needs a strip, a `ring` an aperture, and `contour`/`grid` pin
2304
+ everything to one bone. Without this, every pipeline weights such a layer
2305
+ itself and transcribes the result into authored `weights`.
2306
+
2307
+ ⭐ **Its inputs are the one decision, and everything else is derived.** What
2308
+ you decide is **which segments may pull the part**. The lattice is read off
2309
+ the attachment's own `image`, each segment off the skeleton's setup pose, and
2310
+ every weight off the distance between a vertex and those segments — so the
2311
+ only thing in this block that is a judgement is `bones`, and the numbers
2312
+ beside it say how a distance becomes a share.
2313
+
2314
+ ```json
2315
+ "invariants": { "meshSlots": 1, "meshTriangles": 200 },
2316
+ "skins": {
2317
+ "default": {
2318
+ "robe": {
2319
+ "robe": {
2320
+ "type": "mesh",
2321
+ "image": "robe.png",
2322
+ "generator": {
2323
+ "kind": "segments",
2324
+ "cell": 24,
2325
+ "bones": ["chest", ["skirt_0", "skirt_1", "skirt_2"],
2326
+ { "bone": "hip", "from": [20, 180], "to": [220, 180] }],
2327
+ "falloff": { "power": 2, "radius": 10, "maxBones": 4, "minWeight": 0.03 },
2328
+ "anchor": [120, 96]
2329
+ }
2330
+ }
2331
+ }
2332
+ }
2333
+ }
2334
+ ```
2335
+
2336
+ | Field | Meaning |
2337
+ | --- | --- |
2338
+ | `cell` | **required.** The lattice's square, in the part's pixels — a whole number of at least 1. No default: it is a length on this art, and how fine a mesh the part needs is not something rigc can read off it |
2339
+ | `bones` | **required, at least one entry.** Which segments may pull the part. An entry is one of three forms, below |
2340
+ | `falloff` | **required**, because `radius` is: `{ "power"?, "radius", "maxBones"?, "minWeight"? }` |
2341
+ | `radius` | **required.** Added to every distance before the power is taken, in the part's pixels, so a vertex ON a segment has a finite pull and the blend between two segments is about this wide. Positive |
2342
+ | `power` | the exponent a pull falls off by: `w = 1 / (d + radius)^power`. Positive. Default `2` |
2343
+ | `maxBones` | at most this many bones pull one vertex — the strongest ones. A whole number of at least 1. Default `4` |
2344
+ | `minWeight` | a share under this is dropped, and the kept shares normalised again. In `0.000001`..`1`, 1 excluded. Default `0.03` |
2345
+ | `alpha` | the alpha at or above which a pixel counts as art, `1`..`255`. Default `1` — `contour`'s own |
2346
+ | `anchor` | where the **slot bone** sits in the part's pixels, `[x, y]`, y down. Default the window's centre — the placement every generator on this route uses (the ⭐ above). State it when the art is not drawn centred on its bone: unlike a pinned generator, this one's weights depend on where the art is relative to the bones |
2347
+
2348
+ **The three forms of a `bones` entry**, each resolved by name against the rig:
2349
+
2350
+ | Entry | Its segment(s) |
2351
+ | --- | --- |
2352
+ | `"chest"` — a bone | from the bone's origin to its **`length` tip**, along its setup rotation. A bone with no `length` has an origin and no segment, and is refused |
2353
+ | `["skirt_0", "skirt_1", "skirt_2"]` — a chain, root first | one per link: each link from its origin to the **next link's** origin, the last from its origin to its `length` tip. Each link must be a descendant of the one before it — the list is the chain's order, which is `ribbon`'s `chain` convention |
2354
+ | `{ "bone": "hip", "from": [x, y], "to": [x, y] }` — a span | exactly that segment, in the part's pixels, y down, pulling for `bone`. For a line no bone's own span describes — a hip pulling across a waist, or two meshes over one bone that each want it along a different line. `from` equal to `to` is a point |
2355
+
2356
+ A bone named by two entries keeps the **stronger** of its pulls, and ties
2357
+ between bones keep the order they are first named in.
2358
+
2359
+ **What it does, in order:** a lattice of `cell`-pixel squares over the part,
2360
+ the last column and row clipped to the image; a cell is kept when any pixel in
2361
+ it reaches `alpha`, so the triangles cover **every** art pixel. Then the kept
2362
+ cells are made **one closed outline**: holes filled, every other island joined
2363
+ to the largest by a straight run of cells, every diagonal pinch filled,
2364
+ repeated until a pass changes nothing. That is the case the *"the triangles'
2365
+ outline is not one closed loop"* refusal (above) would otherwise fire on — a
2366
+ layer the alpha splits in two is still one attachment — and the cells the join
2367
+ adds hold no art and draw nothing. Two triangles per cell, the diagonal
2368
+ alternating so the lattice has no preferred shear, the outline walked first
2369
+ (`hull` is the outline's vertex count), and the uvs are the lattice points over
2370
+ the image size. Then, per vertex, `1 / (d + radius)^power` to the nearest point
2371
+ of each segment, the strongest `maxBones` kept and normalised, any share under
2372
+ `minWeight` dropped, the rest normalised again — so every vertex closes at 1 by
2373
+ construction. The emitted mesh is a weighted one, bound by name.
2374
+
2375
+ `build` and `explain` print what it measured, under the mesh's line. This is
2376
+ the selftest's own `segments_probe` (`fixtures/public.ts` in the repository) —
2377
+ one 96x64 plate of two islands under two two-link chains, `cell: 8`,
2378
+ `radius: 4`, `maxBones: 3` — as `build` prints it:
2379
+
2380
+ ```
2381
+ MESH cloth segments 72 vertices / 100 triangles (budget 200) bones=[left_0, left_1, right_0, right_1] attachments=[cloth] covers 100.00% of the art, reaching 8.00px past it
2382
+ influence max 3 bone(s) per vertex, mean 2.61, 4 of 72 vertices (5.56%) on a single bone
2383
+ lattice cell 8px, 12x8 cells, 48 with art, 50 kept (2 island(s) joined into one outline, 2 cell(s) added that hold no art)
2384
+ ```
2385
+
2386
+ The `influence` line is the falloff's report: the most bones any vertex binds
2387
+ (read it against `maxBones`), the mean, and how many vertices one bone owns
2388
+ outright. A named bone that **no** vertex binds is listed there by name — it is
2389
+ in no weight, so no other figure moves — and under `--profile spine-html`
2390
+ `A20_MESH_WEIGHTS_COHERENT` refuses it as a declared bone nothing binds. The
2391
+ `lattice` line says how many cells had art and how many were kept, and when
2392
+ islands were joined, how many and how many cells the join added. The coverage
2393
+ figure is §3.4's authored-mesh one: 100% by construction, and the reach past
2394
+ the art is the lattice's own — up to a cell past the silhouette, and a join run
2395
+ across a gap (the 8.00px above is the two join cells).
2396
+
2397
+ 🔸 **What the gate does with it.** `A20`'s coherence rules and both of its
2398
+ generator-policy branches apply in full. `A21_MESH_RIM_PINNED` **SKIPs** on a
2399
+ segments mesh, by name: its outline is weighted by distance like every other
2400
+ vertex, because a layer pulled by named bones is *supposed* to move at its
2401
+ edge, so "the rim is pinned to the slot bone" is not a claim it makes.
2402
+ `A28_RIBBON_ROWS_SHARE_WEIGHTS` skips it too — a lattice has cells, not cross
2403
+ rows.
2404
+
2405
+ **Stated limits, each a named refusal rather than a mesh that loads wrong:**
2406
+
2407
+ | The input | What you get |
2408
+ | --- | --- |
2409
+ | a bone the rig does not declare | `"bones"[0][1] names mesh bone "nobody", which is not in the rig's bone list` |
2410
+ | an empty `bones` | `the "segments" generator names no bones. "bones" is the one decision this generator takes …` |
2411
+ | a part whose alpha keeps no cell | `no pixel of the 96x64 part reaches alpha 1, so a lattice at cell 8 keeps no cell — there is no art to cover` |
2412
+ | `minWeight` at or above 1, or under `0.000001` | `"falloff.minWeight" is 1; … at 1 or above every share is dropped, and under 0.000001, one step of the 6-decimal grid weights are written on, a kept binding could be written as 0` |
2413
+ | `maxBones` under 1 | `"falloff.maxBones" is 0; it is how many bones may pull one vertex, a whole number of at least 1` |
2414
+ | a vertex every one of whose kept shares is under `minWeight` | `vertex 0, at (0, 0) in the part's pixels, keeps no bone — the 4 strongest pull(s) share it as … and every one is under "minWeight" 0.3` — **refused, not handed the largest.** A bone the falloff dropped is a weight nobody decided; the message names the fix, a `minWeight` at or under `1 / maxBones`, which the strongest share can never fall below |
2415
+ | a bone with no `length` named alone, or as the last link of a chain | `names bone "body", which has no "length", so it has an origin and no segment` — give it a `length`, put it inside a chain, or state a span |
2416
+ | a chain whose next link is not beneath it | `chains "right_0" to "left_1", and "left_1" is not under "right_0" in the bone tree` |
2417
+ | a chain link at its next link's origin | `… the two bones share their setup origin (…), so the link has no length and no direction to weight along` |
2418
+ | an empty chain, a span with a malformed end, an entry of none of the three forms | `is an empty chain`; `both ends are [x, y] in the part's pixels`; `an entry is a bone name, a chain as a list of bone names, or { "bone", "from", "to" }` |
2419
+ | no `falloff`, a `radius` or `power` at or below 0, a malformed `anchor`, a `cell` under 1, no `image` | each named by its field — `radius` has no default because it is a length on this art |
2420
+ | a part on a packed page that declares a `scale:` | refused: `cell` and `radius` are the part's pixels and this generator does not convert them to a page's texels. Build from the loose part, or a pack at scale 1 |
2421
+
2259
2422
  #### 3.4.1 A skin that switches bones and constraints on
2260
2423
 
2261
2424
  **When you need one:** a skin that is more than a change of art — a variant with an
@@ -2591,7 +2754,7 @@ that lists it — see §3.4.1, and note that the flag alone does nothing.
2591
2754
  that is deliberate. Every key on a constraint object is a Spine field the emitter
2592
2755
  writes; a statement to the gate about who turns the dial is what the artifact cannot
2593
2756
  say about itself, so it lives in `invariants.consumerDrivenMix` (§3.7), beside
2594
- `deformMayFold` — the one other exemption from a named rule, which names its subject
2757
+ `deformMayFold` — another exemption from a named rule, which names its subject
2595
2758
  the same way.
2596
2759
 
2597
2760
  #### 3.5.1 `path` — bones that travel along a curve
@@ -3105,7 +3268,7 @@ is not an array. Every field is optional and each is the payload a firing
3105
3268
  Optional with one exception, and only meaningful for rigc's own formations:
3106
3269
  `meshSlots` and `meshTriangles` (the two halves of the mesh budget `A13` measures
3107
3270
  against), `axisBone`, `massBone`, `detached`, `deformMayFold`, `editorRoundTrip`,
3108
- `consumerDrivenMix`.
3271
+ `consumerDrivenMix`, `idleDrivesMeshes`.
3109
3272
  Nothing in skeleton
3110
3273
  JSON records that a
3111
3274
  bone carries a cut's axis or that a parentage is forbidden, so the rig spec says it
@@ -3141,8 +3304,8 @@ loads and still animates and merely lies — something released into the world t
3141
3304
  must not ride the part that released it. [RIGGING.md](RIGGING.md) §10.3 has a
3142
3305
  worked one.
3143
3306
 
3144
- 🚨 **`deformMayFold` is one of the two fields here that turn a check OFF**
3145
- (`consumerDrivenMix`, below, is the other), so it is held to a shape that is
3307
+ 🚨 **`deformMayFold` is one of the three fields here that turn a check OFF**
3308
+ (`consumerDrivenMix` and `idleDrivesMeshes`, below, are the others), so it is held to a shape that is
3146
3309
  refused rather than skipped. It is
3147
3310
  `[{ "slot": …, "why": … }]`, it exempts that slot from
3148
3311
  `A39_DEFORM_KEEPS_TRIANGLE_WINDING`, and three shapes are compile errors: a slot
@@ -3216,6 +3379,37 @@ spelling of `gallery/look`'s rule that a face angle is a value rather than a tim
3216
3379
  - What it buys is a **SKIP by name, never a pass** (§4.12 has the line). The
3217
3380
  declaration is a statement to the gate and changes no emitted byte.
3218
3381
 
3382
+ 🎨 **`idleDrivesMeshes` says this rig's `idle` deforms meshes on purpose.** It is
3383
+ `{ "why": … }`, and it switches off `A15_IDLE_NO_MESH_BONE_KEYS` (§5.2). That rule
3384
+ assumes meshes are **mostly static**: the `spine-html` renderer skips redrawing a
3385
+ mesh nothing moved, so an `idle` keying a mesh's bone spends that skip on every
3386
+ frame. A **painting rig** is the genre where that is false by design — one
3387
+ illustration split into layers, most of them weighted meshes over bone chains, and
3388
+ an `idle` whose job is to move them (hair, sleeves, breathing). Without the field,
3389
+ the only way through is a parent bone above every keyed bone with the keys moved
3390
+ onto it: on the first such rig that was 42 extra bones for an identical pose.
3391
+
3392
+ - Four shapes are compile errors, each naming `invariants.idleDrivesMeshes`: a
3393
+ missing `why`, a blank one, one that is not a string, and anything that is not
3394
+ an object (a bare `true` included, since it is a switch with no reason). A key
3395
+ other than `why` is refused like any unknown key.
3396
+ - What it buys is a **SKIP, never a pass**, and the SKIP states the cost —
3397
+ the keyed bones (all of them up to eight, then the first eight and `+k`), the
3398
+ mesh attachments they drive and the vertices those hold, counted from each mesh's
3399
+ loaded `worldVerticesLength`: `declared by the rig ("<why>"): idle keys N bone(s)
3400
+ that drive M mesh attachment(s) totalling V vertices — "a", "b" — and each of
3401
+ those meshes is recomputed on every frame it is shown`. "Drive" is A15's own reading: the mesh's slot bone, or a bone its weights name.
3402
+ A mesh under a keyed bone's *descendant* also moves and is not counted, so the
3403
+ figures are a floor.
3404
+ - ⛔ **A declaration nothing exercises is refused at the gate**, not skipped: an
3405
+ `idle` that keys no mesh-driving bone, carries no bone timeline, or does not
3406
+ exist fails A15 with `the rig "X" declares invariants.idleDrivesMeshes ("…"),
3407
+ but "idle" keys no bone that drives a mesh, so the declaration switches off
3408
+ nothing — remove it`. That is the `consumerDrivenMix` standard: an opt-out that
3409
+ exempts nothing reads exactly like one that worked.
3410
+ - ⚠️ A15 is a renderer rule, so under `--profile spine` it reports `PROF` and the
3411
+ declaration is not read at all, stale or not.
3412
+
3219
3413
  ---
3220
3414
 
3221
3415
  ## 4. The motion spec, field by field
@@ -5487,20 +5681,20 @@ Fix A00 and run it again.
5487
5681
  | `A12_NO_DARK_COLOR` | renderer | a slot `dark` colour or an `rgba2`/`rgb2` timeline; parsed, then ignored |
5488
5682
  | `A13_MESH_BUDGET` | renderer | more mesh slots than the rig's `invariants.meshSlots`, or a mesh over its `invariants.meshTriangles`. Thin the mesh, or raise the budget in the rig spec. **SKIP** when the rig declares neither — which means *unmeasured*, not that the budget is inert: the same `meshSlots` is a **compile-time** refusal for rigc's own generators, before the gate (§3.7) **SKIP** also when the rig budgets **only** triangles and the skeleton carries no mesh. A declared slot budget still PASSes there, because zero mesh slots is a count measured against a ceiling |
5489
5683
  | `A14_NO_FULL_FRAME_MESH` | renderer | a mesh spans the whole stage — a full-frame canvas that can never dirty-skip. **SKIP** when the skeleton declares no stage (§3.1): there is no full frame to span, and *unmeasured* must not print the same green as *measured and clear* |
5490
- | `A15_IDLE_NO_MESH_BONE_KEYS` | renderer | the `idle` animation keys a bone that drives a mesh, directly or as a control bone. **SKIP** when there is no `idle` animation, or when the one there is carries no bone timeline — a rule whose subject does not exist is unmeasured and not satisfied |
5684
+ | `A15_IDLE_NO_MESH_BONE_KEYS` | renderer | the `idle` animation keys a bone that drives a mesh, directly or as a control bone. The rule assumes meshes are mostly static — the renderer skips redrawing a mesh nothing moved — so it has three states. **FAIL per bone** when the rig declares nothing: `idle keys bone "X", which drives a mesh — meshes never idle-skip`, and the **first** of those lines, once per run, names the case: `K bone(s) keyed by idle drive meshes; if this idle is meant to deform them (a painting rig), declare invariants.idleDrivesMeshes: { "why": … } in the rig spec — or, where the motion belongs to a pivot above the mesh, key that pivot one link up (FACE.md §3)`. It rides the first finding, so the count of FAIL lines is still the count of bones. **SKIP with the cost** when the rig declares `invariants.idleDrivesMeshes` (§3.7): `declared by the rig ("<why>"): idle keys N bone(s) that drive M mesh attachment(s) totalling V vertices — "a", "b" — and each of those meshes is recomputed on every frame it is shown`. **FAIL on a stale declaration** — declared, and the `idle` keys no mesh-driving bone, carries no bone timeline or does not exist: `the rig "X" declares invariants.idleDrivesMeshes ("…"), but "idle" keys no bone that drives a mesh, so the declaration switches off nothing — remove it`. Otherwise **SKIP** when there is no `idle` animation, or when the one there is carries no bone timeline — a rule whose subject does not exist is unmeasured and not satisfied |
5491
5685
  | `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`) |
5492
5686
  | `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 — as it is for `A06`, `A19` and `A27`; see `A07` |
5493
5687
  | `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed. That is a compiler bug, not a spec bug — report it |
5494
5688
  | `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part cannot draw a transparent pixel, so it would paint a solid rectangle over what is behind it. **The texels decide, on both routes**; the file header is only the fast negative. A file with no alpha channel (colour type 4 or 6) and no `tRNS` chunk has nowhere to keep a clear texel and is refused without being opened, by a sentence that names its colour type — so re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. A file whose header says it **could** be transparent is opened and read until its first texel below full alpha; one clear texel passes, and none is refused in the words the packed route uses for a region, naming the file and what it can hold — `is opaque in every one of its 200x80 texels` … `its file can hold transparency — colour type 6 (truecolour + alpha) carries an alpha channel — and no texel uses it`. Saving as RGBA is not the repair: the runtime draws the texels, not the declaration. On a loose page the file is the part, so the whole decoded image is the rectangle and no atlas coordinate is read. Only the base plate may be opaque, and which image that is is decided once for both routes: 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. ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page**: 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**, and the clause above 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 a scan would come 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 **disappears**, the scan finding 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. ⚠️ **A page file that cannot be read as PNG at all is the same non-measurement for every part on it**: one row per page naming its parts and pointing at `A06`, which names what the file is. **SKIP** when the atlas declares no page |
5495
- | `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. Fix the rig spec's `controls`/`chain`, or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment |
5496
- | `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 |
5689
+ | `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. Fix the rig spec's `controls`/`chain`, a `segments` generator's `bones` (its `influence` line names a bone no vertex binds), or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment |
5690
+ | `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. **SKIPs** by name on authored or linked geometry and on a `segments` mesh, whose outline is weighted by distance like the rest of it — none of them claims a pinned rim |
5497
5691
  | `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 |
5498
5692
  | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | both | a physics constraint that drives no component, rests at `mix: 0` with **no timeline in any animation keying that `mix` above 0**, has `mass: 0`, has `strength` at or below 0 — `0` says `nothing pulls it back`, below 0 says the offset `is pushed away and grows with every step`, both read off the row's `outside` arms, which the key's refusal quotes too — or has `damping` outside `[0, 1]`, which says `` physics "C" has damping 1.5; must be inside [0, 1] — the per-step decay is `damping ** (60 * step)`, and the runtime runs this value finitely, so refusing it is rigc's call rather than the runtime's: above 1 every velocity grows on every step and the offset diverges ``, the bound read off the row the key's refusal reads; `1` and `0` are inside, since both are finite at every rate — **at rest, and on every physics timeline key**. The timeline arm reads each key through the runtime's own `PhysicsConstraint*Timeline.set`, so a keyed `mass` is judged as the `massInverse` it becomes, and the detail names the animation, the constraint, the key time, the value and the bound. Two differences between the two arms, and the runtime is the reason for both: a **key** of `mix: 0` is accepted, because `update` opens with `if (mix === 0) return;` and muting a constraint for a stretch is what a mix timeline is for — the editor's own `sack-pro` example keys it there on 24 of its 36 mix keys — and a **key** of `strength: 0` is accepted, because it releases the constraint for the span with `damping` and `inertia` still applied and the next key pulls the offset back, measured through spine-core at no NaN, a coast to a limit and a return in 54 steps. As a **setup** value `strength: 0` is still refused by the arm above, and `mix: 0` is refused only when nothing keys it above 0. The `mix` branch above is why `mix` is the one setup value a key can answer for: at rest the constraint is **inert** rather than broken, so a rig that rests muted and is keyed above 0 is refused by nothing, while a rig resting at `mass: 0` is `massInverse` Infinity before anything plays and no key reaches back into that. The detail of the refusal says both halves and how many animations were searched: `` physics "C" has mix 0 and none of the 3 animations keys its mix above 0; the runtime runs this value finitely, so refusing it is rigc's call rather than the runtime's: at 0 `update` returns before it does anything (`PhysicsConstraint.js:109-111`), so the constraint is muted — rest it above 0, or key its mix above 0 in an animation ``. ⚠️ Every reason A23 prints for a setup value is that value's row `basis` arm (§4.4's table) — an arithmetic arm names its expression (`physics "C" has massInverse Infinity; mass must be > 0 — at 0 …`), a behavioural one says it is rigc's call and what the value does. A setup `mix` **below** 0 is told the jiggle is applied inverted, not that it is muted: such a constraint is measured moving its bone by exactly the opposite of a positive mix. The search counts the unnamed global timeline for every constraint whose own `mixGlobal` is set, reads each key through the runtime's accessor, counts every sample of a Bezier between two keys as a value the timeline poses — so two keys of 0 joined by a curve lifted above 0 are a rescue, measured to move the bone — and takes an animation a slider applies like any other. It is the one reading `A36` and `A37` use as well. `inertia`, `wind`, `gravity` and the top of `mix` are bounded nowhere, at rest or keyed. `ingest` does not carry a constraint that drives no component into the spec it writes: it omits it with its timelines and reports `PHYSICS_DRIVES_NOTHING` ([INGEST §2.0](INGEST.md)), so this sentence is met on a file, never on a decompiled rebuild. **SKIP** when the skeleton declares no physics constraint — the same sentence `A36` and `A37` print for their own constraint types |
5499
5693
  | `A24_AXIS_SPACE_STROKE` | archetype | a bone under the rig's `axisBone` was keyed with a screen-space Y component, or the axis bone itself was keyed. **SKIP** when the rig declares no axis bone, and also when no animation keys that bone or anything under it |
5500
5694
  | `A25_DETACHED_BONE_PARENTAGE` | archetype | a bone the rig declares `detached` is a descendant of the bone it must never hang under |
5501
5695
  | `A26_SLOT_DRAW_ORDER` | archetype | the emitted slots are not the rig's slot table — a slot is out of order, is not in the table at all, or is in the table and missing from the skeleton (§3.3). **SKIP** when the rig declares no canonical slot order. ⚠️ A skeleton with **no** slot beside a rig that declares some is **not** a skip, and it is the one rule in this family where an empty loop is not a vacuous pass: the completeness clause reads it as every declared slot lost and names them |
5502
5696
  | `A27_REGION_NAME_MATCHES_PAGE_FILENAME` | renderer | a single-region page whose region name is not the PNG's basename. **SKIP** when the atlas declares no region |
5503
- | `A28_RIBBON_ROWS_SHARE_WEIGHTS` | archetype | the two vertices of a ribbon row carry different weights, so the strip would change width. **SKIPs** on authored geometry and on a contour mesh — neither has rows rigc paired |
5697
+ | `A28_RIBBON_ROWS_SHARE_WEIGHTS` | archetype | the two vertices of a ribbon row carry different weights, so the strip would change width. **SKIPs** on authored geometry, a contour mesh and a `segments` lattice — none has rows rigc paired |
5504
5698
  | `A29_STROKE_WITHIN_CONTACT_DEPTH` | archetype | the animation drives deeper than the manifest's measured contact depth |
5505
5699
  | `A30_STROKE_WITHIN_CAP_CONTAINMENT` | archetype | the animation drives past the measured containment ceiling, or scales a bone in the axis subtree |
5506
5700
  | `A31_DRAW_ORDER_OFFSETS_RESOLVE` | both | a draw-order key names a slot the skeleton does not have, offsets one slot twice, puts a slot outside the slots array, or lists its offsets out of slot order (§4.7). The only assertion that runs **before** `A00` — the last of those shapes makes the loader spin rather than return, so the round trip is refused instead of attempted |
@@ -8307,8 +8501,9 @@ Per part:
8307
8501
  | `ambiguous` | at least one alternate is inside the ambiguity margin. **Choose with something this instrument cannot see** — anatomy, the other frame, or `rigc vote` |
8308
8502
  | `rotationFree` | the part is self-similar under rotation, so `rotationDeg` is a placeholder and the value is yours |
8309
8503
  | `rotationSelfSimilarity` | the number `rotationFree` is a threshold on. A part just over the line is worth a look |
8310
- | `refusal` | `{ reason, detail }` or `null`. Reasons: `no-match`, `larger-than-canvas`, `empty-part`. A `no-match` whose best placement stopped **on a wall of the search window** names the wall in its detail — see §11.4 |
8504
+ | `refusal` | `{ reason, detail }` or `null`. Reasons: `no-match`, `larger-than-canvas`, `empty-part`. A `no-match` whose best placement stopped **on a wall of the search window** names the wall in its detail — see §11.4 — and every `no-match` with a placement ends with `legibility.reading` — see §11.5 |
8311
8505
  | `coarse` | the grid this part was actually searched on. A handful of cells means the part is small relative to the frame and the first pass had little to go on |
8506
+ | `legibility` | what the part gave the search to work with, and how its answers stood against each other — `null` only when nothing was searched. `width`, `height`: the material box, part px · `span`: its longest side at the best placement's scale, **frame** px · `opaqueShare`: alpha weight over the box's area · `texture`: the part's **texture figure** (§11.5) · `candidates`: distinct placements measured at full resolution · `best`, `next`, `spread`: the best residual, the second distinct one, and `next − best` (`null` with no second) · `floor`: `below` or `above` the measured floor (§11.5), on every part · `reading`: on a refused or ambiguous part, the sentence saying which of the two readings those figures support; `null` on a placed one |
8312
8507
  | `notes` | the same facts in prose, in the order they were found |
8313
8508
 
8314
8509
  Per placement:
@@ -8383,6 +8578,102 @@ holds nothing because it already contains every angle.
8383
8578
  with B — which placement of a repeated part belongs to which limb, across two
8384
8579
  frames — is the authoring job, not this tool's.
8385
8580
 
8581
+ ### 11.5 Too small or too plain — the measured floor
8582
+
8583
+ A refusal or an ambiguity has two readings the residual cannot separate: **the
8584
+ part is wrong**, or **the part is too small or too plain for `pose`** to pin
8585
+ down. A correct cut of a plain sleeve and a foreign part can end in the same
8586
+ verdict. So every searched part carries `legibility`, and a refused or
8587
+ ambiguous one ends with a sentence saying which reading its figures support:
8588
+
8589
+ - **below the floor** — a 40x28 part of one flat colour, cut from a plain block it fits anywhere inside:
8590
+
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
8592
+
8593
+ - **above the floor** — a 200x150 black-and-white checker the frame does not hold:
8594
+
8595
+ > loud.png: the best placement found has residual 0.5000, above --max-residual 0.25; part is 200x150 px (span 200 frame px, opaque 1) with texture 0.2473, detail 49.455; 2 candidate(s), best 0.5000, next 0.5000, spread 0.0000 — above the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up), so size and texture do not explain this; the cut (or the search window) is the suspect
8596
+
8597
+ Read the figures, not only the verdict. *Below* says plainness alone explains the
8598
+ failure: the cut may be right, and `pose` cannot tell. *Above* says it does not,
8599
+ so look at the cut, the `--scale`/`--rotation` window, or — for an ambiguity — at
8600
+ whether the frame really holds the part twice. ⚠️ Above the floor is **not** a
8601
+ promise that `pose` will place the part (see the rates below).
8602
+
8603
+ **The texture figure.** `legibility.texture` is the mean colour step between
8604
+ neighbouring material pixels, `0..1`, in the residual's own units: each pair of a
8605
+ pixel and its right or lower neighbour, both with material, contributes its mean
8606
+ absolute channel difference over 255, weighted by the smaller alpha. A part of
8607
+ one flat colour reads exactly `0`. It is chosen over a colour variance because it
8608
+ is, to first order, **what a one-pixel shift costs**: a part whose residual
8609
+ cannot rise when it moves cannot be told from itself moved. The silhouette is not
8610
+ counted — the material term already reads it, and only where the frame is ground.
8611
+
8612
+ **The floor**, measured with `bun tools/pose_floor.ts`:
8613
+
8614
+ Found within 2 px of the truth, out of 15 trials per cell (free search, default windows):
8615
+
8616
+ | texture level (mean) | 24 px | 32 px | 48 px | 64 px | 96 px | 128 px | 192 px |
8617
+ | --- | --- | --- | --- | --- | --- | --- | --- |
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) |
8622
+
8623
+ The same trials, grouped by `detail`:
8624
+
8625
+ | detail | found | ambiguous | wrong | refused |
8626
+ | --- | --- | --- | --- | --- |
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 |
8636
+
8637
+ **Method.** The truth set is `examples/spineboy`: its setup pose and one frame of
8638
+ `walk` (a third of the way in). For every trial the figure is rendered twice with one
8639
+ viewport — whole on the renderer's ground (the pose frame), and one slot alone on
8640
+ transparency (the part, cropped to its own alpha) — so the truth is the crop's own
8641
+ offset at rotation 0 and scale 1, fitted by nothing. Slots with at least 75% of their
8642
+ material visible take part: 8 in the setup pose and 7 in the walk frame, which makes 15
8643
+ trials per cell. The size is set by the viewport, so the part's longest side is the
8644
+ column's size in frame pixels. The texture is set on the atlas region the slot samples,
8645
+ so the part and the frame carry the same plainer art: `native`; `blur1`/`blur2`, a box
8646
+ blur of 3% and 10% of the region's longest side; `flat`, one alpha-weighted mean colour.
8647
+ A trial is *found* when it is neither refused nor ambiguous and its centre is within
8648
+ 2 px of the truth.
8649
+
8650
+ **The figure the floor is stated in** is `legibility.detail` = `texture` × the material
8651
+ box's longest side in part pixels: the colour change a walk along the part
8652
+ accumulates. The per-pixel `texture` alone does not predict the outcome — an upscaled
8653
+ part has a low per-pixel gradient and still places — and size alone has no
8654
+ measurable effect across 24–192 px at any level.
8655
+
8656
+ **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
8659
+ the floor when its `detail` is under 0.5, or when its own longest side is under 24 px,
8660
+ the smallest size measured (there, nothing measured says `pose` can place it).
8661
+
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.
8671
+
8672
+ Re-run it with `bun tools/pose_floor.ts --textures <level> --json <out>` (one level per
8673
+ 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.
8676
+
8386
8677
  ## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
8387
8678
 
8388
8679
  ```bash