spine-rigc 1.3.0 → 1.4.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
@@ -145,6 +145,10 @@ import {
145
145
  FRAMES_SIDECAR,
146
146
  FRAMES_SPEC,
147
147
  framingViewport,
148
+ GEOMETRY_FILE,
149
+ GeometryError,
150
+ geometryFileOf,
151
+ geometryText,
148
152
  loadPosable,
149
153
  PROTOCOL_FPS,
150
154
  renderFrame,
@@ -153,6 +157,7 @@ import {
153
157
  SETUP_POSE_DIR,
154
158
  SHEET_FILE,
155
159
  SHEET_TILE,
160
+ sidecarViewport,
156
161
  SlotSubsetError,
157
162
  slotSubsetOf,
158
163
  type Frame,
@@ -259,7 +264,7 @@ function repositoryUrl(): string {
259
264
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
260
265
  * halves together by reading `--help` rather than by naming a flag.
261
266
  */
262
- const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy']);
267
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry']);
263
268
 
264
269
  /**
265
270
  * The flags a command is allowed to spell more than once.
@@ -2010,6 +2015,24 @@ function cmdRender(flags: Record<string, string>): void {
2010
2015
  const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
2011
2016
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
2012
2017
  const outRoot = resolve(flags.out ?? 'render');
2018
+ const geometry = flags.geometry !== undefined;
2019
+ // Refused together rather than one of them ignored (issue #864). The export
2020
+ // records every slot's whole geometry, because a subset is a statement about
2021
+ // which pixels are DRAWN and the pose is the same whatever a picture leaves
2022
+ // out — so a geometry.json beside a subset's frames would describe slots
2023
+ // those frames do not draw, and one that dropped them would stop being the
2024
+ // pose. The whole-rig run writes the same file either way.
2025
+ if (geometry) {
2026
+ for (const flag of ['slot', 'hide'] as const) {
2027
+ if (flags[flag] !== undefined) {
2028
+ throw new UsageError(
2029
+ `--geometry takes no --${flag}: it records every slot's whole geometry, which a subset of the drawn slots ` +
2030
+ `does not change. Run \`rigc render --geometry\` on the whole rig for the file, and --${flag} ` +
2031
+ `${flags[flag]} without --geometry for the pictures — both land on the same grid`,
2032
+ );
2033
+ }
2034
+ }
2035
+ }
2013
2036
 
2014
2037
  console.log('rigc render');
2015
2038
  console.log(` .. skeleton ${skeletonPath}`);
@@ -2043,8 +2066,24 @@ function cmdRender(flags: Record<string, string>): void {
2043
2066
  // `sampleAll` covers the skeleton with no animation at all, which files its one
2044
2067
  // setup-pose frame under the reserved name. Narrowing to one animation reuses
2045
2068
  // the same sampler rather than a second path through it.
2069
+ //
2070
+ // `--geometry` rides on the SAME call (issue #864): the bones and the whole
2071
+ // attachments are read off the skeleton at the step that drew each frame, so
2072
+ // the export's grid is this frame set's by construction.
2073
+ const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
2046
2074
  const sampled: Map<string, Frame[]> =
2047
- only === undefined ? sampleAll(data, fps, pose) : new Map([[only, sampleAnimation(data, only, fps, pose)]]);
2075
+ only === undefined
2076
+ ? sampleAll(data, fps, sampling)
2077
+ : new Map([[only, sampleAnimation(data, only, fps, sampling)]]);
2078
+ // Every file's text before the first write, so a refused number leaves the
2079
+ // output directory as it was rather than half of a frame set behind it.
2080
+ const geometryTexts = new Map<string, string>();
2081
+ if (geometry) {
2082
+ for (const [name, frames] of sampled) {
2083
+ const animation = name === SETUP_POSE_DIR && data.animations.length === 0 ? null : name;
2084
+ geometryTexts.set(name, geometryText(geometryFileOf(data, animation, fps, frames, viewport, skin)));
2085
+ }
2086
+ }
2048
2087
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
2049
2088
  if (!declaresSetupStage(data)) console.log(` .. ${STAGELESS_FRAMING.render}`);
2050
2089
 
@@ -2067,6 +2106,8 @@ function cmdRender(flags: Record<string, string>): void {
2067
2106
  // would be the same picture with a border and a "0" on it.
2068
2107
  const sheet = frames.length > 1;
2069
2108
  if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
2109
+ const geometryOut = geometryTexts.get(name);
2110
+ if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
2070
2111
  const duration = frames[frames.length - 1].time;
2071
2112
  sets.push({
2072
2113
  dir: dirName,
@@ -2078,7 +2119,8 @@ function cmdRender(flags: Record<string, string>): void {
2078
2119
  duration,
2079
2120
  });
2080
2121
  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}`);
2122
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}`;
2123
+ console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
2082
2124
  }
2083
2125
 
2084
2126
  // The sidecar is what makes this a frame SET rather than a pile of pictures:
@@ -2095,15 +2137,9 @@ function cmdRender(flags: Record<string, string>): void {
2095
2137
  // every slot says nothing and stays the bytes it always was (issue #835).
2096
2138
  ...subsetFields(subset),
2097
2139
  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
- },
2140
+ // The one spelling `geometry.json` repeats, so the two files cannot state
2141
+ // two boxes for one grid.
2142
+ viewport: sidecarViewport(viewport),
2107
2143
  sets: [...sets].sort((a, b) => a.dir.localeCompare(b.dir)),
2108
2144
  };
2109
2145
  writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
@@ -3808,6 +3844,10 @@ const FLAG_MEANINGS: Record<string, string> = {
3808
3844
  'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
3809
3845
  'as a measurement',
3810
3846
  'all-bones': 'print every bone pair, not just the worst by position',
3847
+ geometry:
3848
+ `also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
3849
+ "slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
3850
+ 'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
3811
3851
  'texture-from':
3812
3852
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
3813
3853
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -4148,10 +4188,10 @@ const COMMANDS: CommandDoc[] = [
4148
4188
  {
4149
4189
  name: 'render',
4150
4190
  usage: [
4151
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--out render/]',
4191
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--out render/]',
4152
4192
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
4153
4193
  ],
4154
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'out'],
4194
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'out'],
4155
4195
  overrides: {
4156
4196
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
4157
4197
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -4499,6 +4539,13 @@ try {
4499
4539
  // the one reader's and already names the file, what it is and what rigc
4500
4540
  // reads; a stack under it is the tool describing its own internals instead.
4501
4541
  // Exit 1, like a compile error: the invocation was fine, a file was not.
4542
+ // A pose the geometry export cannot write as numbers (issue #864): the
4543
+ // invocation was fine and the skeleton posed a NaN or an infinity, so exit 1
4544
+ // like a file that is not a PNG. Raised before the first file is written.
4545
+ if (err instanceof GeometryError) {
4546
+ console.error(`rigc render: ${err.message}`);
4547
+ process.exit(1);
4548
+ }
4502
4549
  if (err instanceof NotAPngError) {
4503
4550
  console.error(`rigc: ${err.message}`);
4504
4551
  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,31 @@ 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
+ moves that set's unturned page by 18.13 %, and trying every width instead
345
+ finds an unturned page 10.22 % smaller. With every width tried, no set gains
346
+ more than 5.05 % from turning — so the packer still never turns a region.
321
347
  - **no re-ordering of anything the skeleton says.** `skeleton.json` from a packed
322
348
  build is **byte-identical** to the unpacked one, because sizes are still
323
349
  measured from the loose PNGs and packing is an output arrangement.
@@ -827,6 +853,7 @@ bun cli.ts check --candidate path/to/spine --frames path/to/frames [--skin
827
853
  bun cli.ts bench 3 --candidate path/to/spine [--frames path/to/frames]
828
854
  bun cli.ts render --candidate path/to/spine [--animation …] [--skin …] [--fps 12] [--max 256]
829
855
  bun cli.ts render --candidate path/to/spine --slot <name,…> | --hide <name,…> # part of the rig, same grid
856
+ bun cli.ts render --candidate path/to/spine --geometry # + geometry.json per set: the numbers the frames were drawn from
830
857
  bun cli.ts preview --candidate path/to/spine [--candidate path/to/another …] [--animation …] [--out preview.html]
831
858
  bun cli.ts vote --candidate path/to/a --candidate path/to/b [--out ballot.html]
832
859
  bun cli.ts vote --record vote-<id>.json [--ballot ballot.html] [--ledger votes.jsonl]
@@ -921,7 +948,11 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
921
948
  in draw order; a slot whose art lives only under another skin is refused naming
922
949
  that skin (pass `--skin`); the two flags together are refused. `frames.json`
923
950
  records the subset as `slots` or `hidden`, and **`check` refuses such a set as
924
- a reference** (§9). `preview`
951
+ a reference** (§9). `--geometry` writes the numbers each frame was drawn from
952
+ beside it — every bone's world transform and every attachment's world vertices
953
+ after skinning, with their rest geometry — for a judgement the pixels cannot
954
+ make: a mesh triangle's stretch, a region read in its own bone's frame (§8.2).
955
+ `preview`
925
956
  writes one self-contained `.html`: your skeleton, atlas and page PNGs are
926
957
  embedded in it as data URIs and played by the official **Spine Web Player**, so
927
958
  double-clicking it is also the interop proof — what plays there was played by
@@ -6262,6 +6293,119 @@ the loop with **§9**: the fit's own number says how near this pose is to this f
6262
6293
  and only `check` says whether the shot is the shot. **§9.3** is the list of what even
6263
6294
  that cannot see.
6264
6295
 
6296
+ ### 8.2 The numbers the frames were drawn from — `render --geometry`
6297
+
6298
+ A frame set is pixels, and some judgements about a rig are not about pixels: how far
6299
+ a mesh triangle is stretched over its rest shape, or whether a region holds still in
6300
+ its own bone's frame. Both need the posed numbers, and both need them **on the frames'
6301
+ own grid** — a second tool re-sampling the animation would step the runtime its own
6302
+ way and drift from the pictures it is meant to explain. So `render --geometry` writes
6303
+ them beside the pictures, off the very frames it drew:
6304
+
6305
+ ```bash
6306
+ bun cli.ts render --candidate path/to/spine --geometry [--animation …] [--skin …] [--fps 12]
6307
+ ```
6308
+
6309
+ Each frame directory gains a `geometry.json` beside its `f####.png`. Nothing else
6310
+ changes: the PNGs, `contact.png` and `frames.json` are byte for byte what the same
6311
+ command writes without the flag. The export costs a small fraction of the render —
6312
+ measured on `spineboy-pro` at 12 fps, 190 frames: about 30 ms for the eleven files
6313
+ against about 610 ms for the rasterising alone — which is why there is no
6314
+ geometry-only mode: a consumer judging frames wants the frames, and skipping them
6315
+ would save little.
6316
+
6317
+ ⛔ **Not with `--slot`/`--hide`.** The two are refused together by name, exit 2,
6318
+ nothing written. A subset is a statement about which pixels are *drawn*; the
6319
+ geometry is the whole pose whatever a picture leaves out, so beside a subset's frames
6320
+ it would describe slots those frames do not draw. The whole-rig run writes the same
6321
+ file either way.
6322
+
6323
+ **The file, field by field.** `spec` is `rigc-geometry/1`. The header fields sit one
6324
+ per line and each `rest` entry and each frame on a line of its own, so a diff of two
6325
+ exports names the frame that moved. Two exports of one skeleton are byte-identical.
6326
+
6327
+ | Field | What it holds |
6328
+ | --- | --- |
6329
+ | `coordinates` | the string `spine world, y up, world units` — every position below is in the skeleton's world, not frame pixels |
6330
+ | `animation` | the animation, or `null` for a skeleton with none (its one frame is the setup pose) |
6331
+ | `skin` | the skin these frames were posed under — absent when none was set, exactly as in `frames.json` |
6332
+ | `fps` | the rate the frames were sampled at |
6333
+ | `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 |
6334
+ | `bones` | every bone in declaration order: `{ name, parent }`, `parent` `null` for the root |
6335
+ | `slots` | every slot in declaration order: `{ name, bone }` — the bone the slot hangs from |
6336
+ | `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) |
6337
+ | `setup` | the setup pose, sampled the way a skeleton with no animation is rendered: `{ bones, attachments }` |
6338
+ | `frames` | one per PNG, in index order: `{ index, time, bones, attachments }`. `index` is the number in `f####.png`, `time` is `index / fps` in seconds |
6339
+
6340
+ Inside `setup` and each frame:
6341
+
6342
+ - `bones` — every bone in declaration order: `{ name, a, b, c, d, worldX, worldY }`,
6343
+ the world transform, so a point `(lx, ly)` in the bone's own frame lands at
6344
+ `(a·lx + b·ly + worldX, c·lx + d·ly + worldY)`. The matrix is complete — rotation,
6345
+ scale and shear all live in `a b c d`.
6346
+ - `attachments` — every slot showing a region or a mesh, in the frame's **draw
6347
+ order**: `{ slot, attachment, vertices, color }`. `vertices` are `x, y` per vertex
6348
+ in world units after skinning and deform — the runtime's own
6349
+ `computeWorldVertices` over the **whole** attachment. A region's four are its
6350
+ corners in the order bottom-left, top-left, top-right, bottom-right; a mesh's are
6351
+ in the attachment's own order, so vertex `i` is the same vertex in every frame and
6352
+ in `rest`. `color` is the slot colour times the attachment colour, straight RGBA
6353
+ `0..1` — the same tint the frame was drawn with.
6354
+
6355
+ What `rest` means, precisely: the **setup pose's bones with the slot's deform
6356
+ empty**, taken for every attachment the file shows — including one the setup pose
6357
+ does not show, which a slot only swaps to later. For an attachment the setup pose
6358
+ does show, its `rest` vertices and its `setup` vertices are the same numbers bit for
6359
+ bit, because they come off one posed skeleton.
6360
+
6361
+ ⚠️ **What is deliberately not in it.** Slots `--slot`/`--hide` would leave out are in
6362
+ it (see above). A clip is not applied: a clipping attachment removes pixels, and the
6363
+ clipper's output is a new triangle list whose vertices are not the attachment's and
6364
+ are not numbered like them — so `vertices` are the attachment's own, and the frames
6365
+ show what the clip left of them. Bounding boxes, points, paths and clipping polygons
6366
+ draw nothing and are not listed. Numbers are printed the way every JSON file this
6367
+ tool writes prints them — the shortest decimal that reads back as the same double —
6368
+ so a vertex in the file *is* the runtime's vertex, not a rounding of it; a pose that
6369
+ is not finite is refused naming the frame, the slot, the attachment and the vertex,
6370
+ rather than written as `null`.
6371
+
6372
+ **The two judgements the file was designed for**, written once so a consumer
6373
+ implements what the file promises:
6374
+
6375
+ 1. **Texture stretch of a mesh triangle.** For each `rest` entry of kind `mesh` and
6376
+ each triangle `(i, j, k)` in its `triangles`, over every frame whose `attachments`
6377
+ carry the same `slot` and `attachment`:
6378
+
6379
+ `stretch = max over frames, max over the edges (i,j) (j,k) (k,i) of |posed edge| / |rest edge|`
6380
+
6381
+ where an edge's length is `hypot(x_j − x_i, y_j − y_i)` over that frame's
6382
+ `vertices` and over `rest`'s. The worst triangle is named by its `slot`, its
6383
+ triangle index and its three vertex indices. A value of exactly `1` is a
6384
+ triangle no frame moved. It is measured against rest, so it counts what the
6385
+ bones do to a weighted mesh as well as what a deform key does — unlike the
6386
+ `DEFORM` block, which compares a key against the same bones with the deform
6387
+ cleared.
6388
+ 2. **A region still in its bone's own frame.** Take the region's `slot`, look its
6389
+ `bone` up in `slots`, and for each frame carry its four corners into that bone's
6390
+ frame by the inverse of the bone's world transform:
6391
+
6392
+ `det = a·d − b·c`, `dx = x − worldX`, `dy = y − worldY`,
6393
+ `lx = (d·dx − b·dy) / det`, `ly = (−c·dx + a·dy) / det`
6394
+
6395
+ Against the same four carried from `setup`, the largest difference over frames is
6396
+ the region's motion *relative to its bone*: zero, to double rounding, for a region
6397
+ nothing but its bone moves. A face region measured against a head bone this way
6398
+ is the face with the head's own turn, translation and scale taken out.
6399
+
6400
+ **Measured on the example corpus** at 12 fps (these are one run's figures, not
6401
+ thresholds): `spineboy-pro`'s eleven files total 5.1 MB, from 72 kB (`aim`, one
6402
+ frame) to 1.46 MB (`death`, 60 frames), and `sack-pro`'s four 1.4 MB. The largest
6403
+ stretch is `sack-pro` `fall-in`, `cape-back` triangle 15, ×6.744; the largest
6404
+ still-in-frame residual over every region of `spineboy-pro` is 4.7e-12 world units
6405
+ (`portal-streaks2`, whose bone scales; `sack-pro` has no region).
6406
+ Topology and UVs sit in `rest` rather than on every frame because they do not
6407
+ change: repeating them per frame would add 35 % to `spineboy-pro`'s files.
6408
+
6265
6409
  ---
6266
6410
 
6267
6411
  ## 9. Checking against the frames — `rigc check`
@@ -8588,7 +8732,7 @@ ambiguous one ends with a sentence saying which reading its figures support:
8588
8732
 
8589
8733
  - **below the floor** — a 40x28 part of one flat colour, cut from a plain block it fits anywhere inside:
8590
8734
 
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
8735
+ > part is 40x28 px (span 40 frame px, opaque 1) with texture 0, detail 0; 14 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 8% 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
8736
 
8593
8737
  - **above the floor** — a 200x150 black-and-white checker the frame does not hold:
8594
8738
 
@@ -8615,24 +8759,24 @@ Found within 2 px of the truth, out of 15 trials per cell (free search, default
8615
8759
 
8616
8760
  | texture level (mean) | 24 px | 32 px | 48 px | 64 px | 96 px | 128 px | 192 px |
8617
8761
  | --- | --- | --- | --- | --- | --- | --- | --- |
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) |
8762
+ | `native` (detail 3.49) | 12/15 (3 amb) | 14/15 (1 amb) | 13/15 (2 amb) | 15/15 | 12/15 (2 amb, 1 wrong) | 14/15 (1 wrong) | 14/15 (1 wrong) |
8763
+ | `blur1` (detail 1.58) | 10/15 (5 amb) | 12/15 (2 amb, 1 wrong) | 13/15 (2 amb) | 13/15 (2 amb) | 12/15 (1 amb, 2 wrong) | 11/15 (1 amb, 3 wrong) | 11/15 (2 amb, 2 wrong) |
8764
+ | `blur2` (detail 0.59) | 7/15 (7 amb, 1 wrong) | 8/15 (5 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) | 6/15 (2 amb, 7 wrong) |
8765
+ | `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
8766
 
8623
8767
  The same trials, grouped by `detail`:
8624
8768
 
8625
8769
  | detail | found | ambiguous | wrong | refused |
8626
8770
  | --- | --- | --- | --- | --- |
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 |
8771
+ | under 0.25 | 8/119 | 89 | 22 | 0 |
8772
+ | 0.25–0.5 | 2/3 | 0 | 1 | 0 |
8773
+ | 0.5–0.75 | 44/81 | 16 | 21 | 0 |
8774
+ | 0.75–1 | 17/34 | 14 | 3 | 0 |
8775
+ | 1–1.5 | 24/25 | 1 | 0 | 0 |
8776
+ | 1.5–2 | 34/48 | 11 | 3 | 0 |
8777
+ | 2–3 | 36/44 | 5 | 3 | 0 |
8778
+ | 3–4 | 33/34 | 1 | 0 | 0 |
8779
+ | 4 and over | 29/32 | 3 | 0 | 0 |
8636
8780
 
8637
8781
  **Method.** The truth set is `examples/spineboy`: its setup pose and one frame of
8638
8782
  `walk` (a third of the way in). For every trial the figure is rendered twice with one
@@ -8654,25 +8798,42 @@ part has a low per-pixel gradient and still places — and size alone has no
8654
8798
  measurable effect across 24–192 px at any level.
8655
8799
 
8656
8800
  **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
8801
+ which at most a tenth of the trials were found: **`detail` 0.5**. Under it, **8.2%**
8802
+ of the trials were found (10/122); at or over it, **72.8%** (217/298). A part is below
8659
8803
  the floor when its `detail` is under 0.5, or when its own longest side is under 24 px,
8660
8804
  the smallest size measured (there, nothing measured says `pose` can place it).
8661
8805
 
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.
8806
+ **What the search carries, and why.** The coarse pass scores the part at anchor
8807
+ cells an **eighth** of its own span apart, and every scale rung sends its **five**
8808
+ best distinct places down to the finer levels. Both were set on this grid (issue
8809
+ #865): of the 65 failures whose truth scored better than every answer reported, 38
8810
+ had a truth that beat its whole scale rung at the coarse level while the anchor cell
8811
+ holding it did not — at a quarter-span stride a thin gun or a gapped fist falls between
8812
+ two anchors — and only 3 had it ranked under the old cutoff of three. The finer grid
8813
+ took the grid from 185 to 227 trials found and those misses from 65 to 14, for about
8814
+ 10–14% more CPU over the grid (615 s of user time before it, 678 s and 704 s on two runs after).
8815
+
8816
+ ⚠️ **What this is and is not.** The grid is not a reliability line: four native cells
8817
+ place 14 or 15 of 15 and the trials at `detail` 3 and over place 62 of 66, but the
8818
+ goggles still place 10 of 42, and a threshold read off two dozen parts of one figure
8819
+ would be a claim about that figure. What the grid can separate is **whose** failure a
8820
+ failure is. `bun tools/pose_floor.ts` prints the residual at the truth for every
8821
+ failed trial and marks it **missed** when the truth scores better than everything
8822
+ reported and nothing reported is on it — a failure the search could fix. The other
8823
+ failures are the objective preferring another placement (a blurred part shrunk into
8824
+ its own region scores better than its truth) or the truth reported and tied, and no
8825
+ search can fix those. After the change, 14 of the 193 failed trials are misses, and 13
8826
+ of those sit within 7 px of the truth rather than on another hill. All this was
8827
+ measured on **ideal cuts**, the frame's own pixels, where a residual at the truth is
8828
+ near zero. A generated cut also disagrees with its picture, which can only make it
8829
+ harder to place. So the edge is where `pose` fails even on ideal cuts, and never a
8830
+ guarantee above it.
8671
8831
 
8672
8832
  Re-run it with `bun tools/pose_floor.ts --textures <level> --json <out>` (one level per
8673
8833
  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.
8834
+ print the table, derive the edge and list the misses per cell. `PO22` in
8835
+ `bun run selftest` re-measures three of its cells, `PO24` holds those three cells to
8836
+ no miss at all, and `PO23` places the 48 px fist this change was made for.
8676
8837
 
8677
8838
  ## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
8678
8839
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "1.3.0",
3
+ "version": "1.4.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": {
package/src/atlas.ts CHANGED
@@ -518,6 +518,37 @@ export interface EmitPage {
518
518
  * ([`src/validate.ts`](validate.ts)) and `artUvsOf`
519
519
  * ([`src/render.ts`](render.ts)), which returns no art UVs for a turned region
520
520
  * attachment, so `check`'s texture substitution would report it unmatched.
521
+ *
522
+ * 📏 **Measured again for issue #866, on twelve region sets, and held back again.**
523
+ * The sets are the two painting rigs above and ten production rigs of 20 to 22
524
+ * parts. Each was packed by this file's own search at padding 2 twice: as
525
+ * shipped, and with every cell also tried turned under the same BSSF score
526
+ * (ties go to the unturned cell). The shipped search reproduced the page every
527
+ * one of the twelve atlases was written at. Area change from turning:
528
+ *
529
+ * | set | `pot` | `pot` + turn | `free` | `free` + turn |
530
+ * | --- | --- | --- | --- | --- |
531
+ * | 22-part painting | 1024x2048 | 0.00 % | 1888x697 | −1.14 % |
532
+ * | 20-part painting | 512x2048 | 0.00 % | 480x1166 | +2.31 % |
533
+ * | P1 | 512x2048 | 0.00 % | 416x1633 | +1.43 % |
534
+ * | P2 | 1024x1024 | 0.00 % | 1184x810 | +0.70 % |
535
+ * | P3 | 512x1024 | 0.00 % | 480x1000 | +2.96 % |
536
+ * | P4 | 1024x2048 | 0.00 % | 864x1346 | −6.16 % |
537
+ * | P5 | 2048x2048 | 0.00 % | 1344x1768 | −13.37 % |
538
+ * | P6 | 1024x2048 | 0.00 % | 800x1934 | −1.62 % |
539
+ * | P7 | 512x2048 | 0.00 % | 1024x928 | −0.05 % |
540
+ * | P8 | 512x2048 | 0.00 % | 608x1250 | −4.96 % |
541
+ * | P9 | 1024x2048 | +100.00 % | 1024x2037 | +6.51 % |
542
+ * | P10 | 512x2048 | 0.00 % | 352x1433 | +0.70 % |
543
+ *
544
+ * Under `pot` a turn shrank no page on any set, and on P9 an ungated one doubled
545
+ * it. Under `free` the largest gain, P5's 13.37 %, is inside the search's own
546
+ * noise: the nearest other width on the `FREE_EDGE_STEP` grid moves P5's
547
+ * unturned page by 18.13 %, and searching every width instead of every 32nd
548
+ * finds an unturned P5 page 10.22 % smaller with nothing turned. With every
549
+ * width searched, the largest turn gain on any set is 5.05 % (P5), then 4.96 %
550
+ * (P8) and 4.22 % (P4). That is not a page worth two readers changing, so the
551
+ * packer still never turns a region.
521
552
  */
522
553
  export const PACK_NO_ROTATE = 0;
523
554
 
package/src/pose.ts CHANGED
@@ -116,15 +116,63 @@ export const COARSE_LONG_SIDE = 40;
116
116
  */
117
117
  export const COARSE_PART_SPAN = 10;
118
118
 
119
+ /**
120
+ * Pixels the part's SHORT side must still span at the coarse level — the same
121
+ * argument as `COARSE_PART_SPAN`, applied to the axis that rule does not read.
122
+ *
123
+ * ⚠️ Found by the change that tightened the coarse grid (issue #865), not by the
124
+ * floor grid, whose parts are none of them thin enough to reach it. MOTION.md
125
+ * §6's 60x14 signal arm is 3.5 px thick at the 4x level its length chose; on the
126
+ * quarter-span grid an anchor cell happened to sit where refinement found its
127
+ * truth (residual 0.0305), and on the eighth-span grid none did, so pose B
128
+ * reported a placement 15 px down the arm at 0.1482. A part three pixels thick
129
+ * at the coarse level has no across-the-part signal for any grid to read. 4, 5
130
+ * and 6 were each measured to move that arm up one level and place it; 4 is the
131
+ * least, and at 4 no trial of the floor grid moves at all.
132
+ */
133
+ const COARSE_PART_THICKNESS = 4;
134
+
119
135
  /**
120
136
  * Coarse anchor positions are stepped at a fraction of the part's own size.
121
137
  *
122
- * The objective varies over distances of order the part, not of order a pixel, so
123
- * scanning every pixel of the coarse level buys resolution the refinement stages
124
- * supply anyway — and it is what makes the scan's cost grow with the frame's area
125
- * instead of with the number of places the part could plausibly be.
138
+ * Scanning every pixel of the coarse level would make the scan's cost grow with
139
+ * the frame's area instead of with the number of places the part could plausibly
140
+ * be, so the anchors are strided. How far apart is the part's own business.
141
+ *
142
+ * ⚠️ An eighth, not the quarter it was, and the reason is measured rather than
143
+ * assumed (issue #865). "The objective varies over distances of order the part"
144
+ * is true of a solid part and false of a thin or holey one: a gun whose material
145
+ * is a third of its box, or a fist with gaps between the fingers, loses most of
146
+ * its fit a quarter-span off its truth. On `tools/pose_floor.ts`'s grid, 65
147
+ * failed trials had a truth that scored better than the answer reported and was
148
+ * missing from the final list; in 38 of them the truth, evaluated exactly at the
149
+ * coarse level, beat every cell of its scale rung, and the anchor CELL holding it
150
+ * did not — the basin fell between two anchors. A cutoff on how many minima
151
+ * survive (3 of the 65) was not what lost them. At an eighth the grid found
152
+ * 226 of 420 trials against 185 (with `MINIMA_PER_SCALE` still at three), and
153
+ * the search misses fell from 65 to 15, for 10–14% more CPU over the grid.
154
+ *
155
+ * 🔸 Not free of losses, and they are stated rather than averaged away: 9 trials
156
+ * that placed on the quarter grid do not on this one. Five of them are blurred
157
+ * parts that now come back ambiguous between two scales at the same spot, or
158
+ * 2.0 px off against a 2 px bar; three are a basin reached and then polished
159
+ * onto a worse placement beside it, and one a basin ranked out of the
160
+ * refinement's twelve — the refinement, not this grid.
161
+ */
162
+ export const COARSE_STRIDE_FRACTION = 0.125;
163
+
164
+ /**
165
+ * Grid cells around a coarse cell that must not beat it for it to be a minimum,
166
+ * and the spacing kept between the minima one scale rung sends down.
167
+ *
168
+ * Four at an eighth-span stride is, nominally, the half-span the old two cells
169
+ * at a quarter held (the stride is rounded to whole level pixels, so only
170
+ * nominally). Measured on the grid: leaving it at two when the stride halved let one
171
+ * wrong hill fill a rung's `MINIMA_PER_SCALE` with its own neighbours — 223
172
+ * trials found rather than 226, and 12 that placed on the old grid lost rather
173
+ * than 9 (both at three minima per rung).
126
174
  */
127
- export const COARSE_STRIDE_FRACTION = 0.25;
175
+ const COARSE_SUPPRESSION_CELLS = 4;
128
176
 
129
177
  /** How many scale rungs one octave gets in the coarse ladder. */
130
178
  export const SCALE_STEPS_PER_OCTAVE = 3;
@@ -180,8 +228,20 @@ export const BACKGROUND_TOLERANCE = 10;
180
228
  /** Share of the frame's border ring one colour must hold before it is called the background. */
181
229
  export const BACKGROUND_BORDER_SHARE = 0.6;
182
230
 
183
- /** How many distinct places each coarse scale rung sends down for refinement. */
184
- const MINIMA_PER_SCALE = 3;
231
+ /**
232
+ * How many distinct places each coarse scale rung sends down for refinement.
233
+ *
234
+ * ⚠️ Five rather than three, and read off the grid rather than off a probe
235
+ * (issue #865). The card's own probe raised this to ten and recovered the fist it
236
+ * was chasing; on the full grid that bought 2 trials net (3 gained, 1 lost),
237
+ * because only 3 of the 65 trials the search missed had their truth's basin
238
+ * ranked under the cutoff — the rest had fallen between anchor cells, which is
239
+ * `COARSE_STRIDE_FRACTION`'s job. With the stride fixed, one miss of that kind was
240
+ * left (a 48 px fist, its basin fifth on its rung), and five is the count that
241
+ * carries it: 227 trials found against 226 at three, none lost, and a CPU cost
242
+ * inside the grid's run-to-run noise.
243
+ */
244
+ const MINIMA_PER_SCALE = 5;
185
245
 
186
246
  /** How many candidates survive each refinement level. */
187
247
  const REFINE_CANDIDATES = 12;
@@ -224,8 +284,8 @@ export const POSE_FLOOR = {
224
284
  span: 24,
225
285
  detail: 0.5,
226
286
  /** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
227
- rateBelow: 0.066,
228
- rateAbove: 0.594,
287
+ rateBelow: 0.082,
288
+ rateAbove: 0.728,
229
289
  };
230
290
 
231
291
  /** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
@@ -266,8 +326,13 @@ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: '
266
326
  // ⚠️ "Above the floor" is not "the search found the right hill". Measured on
267
327
  // the floor's own material, a part above it came back ambiguous between two
268
328
  // equally WRONG placements (residual 0.148 each, 173 px off) while the truth
269
- // scored 0.035 — a basin the coarse pass never sent down. So the sentence
270
- // names that reading too, with the one remedy that separates it: a window.
329
+ // scored 0.035 — a basin the coarse pass never sent down. Issue #865 fixed
330
+ // that one (the coarse grid had stepped over it), and the reading still
331
+ // occurs: on the grid after it, 10 ambiguous trials above the floor had a
332
+ // truth scoring better than both answers — native 24 px gun and shin, 48 px
333
+ // arm, 96 px goggles twice, and blurred gun, goggles and rear shin from 48 px
334
+ // up. Nine of the ten sat 2–6 px from the truth rather than on another hill. So the sentence names that reading too, with the one remedy that
335
+ // separates it: a window.
271
336
  return (
272
337
  `${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
273
338
  (verdict === 'refused'
@@ -1623,9 +1688,18 @@ function placePart(
1623
1688
  // only about the frame. The pyramid stops when the frame fits in
1624
1689
  // COARSE_LONG_SIDE; this then walks back UP it until the part still spans
1625
1690
  // COARSE_PART_SPAN pixels there, because a level that has reduced the part to
1626
- // three pixels cannot say where the part is at any price.
1691
+ // three pixels cannot say where the part is at any price — and until its short
1692
+ // side still spans COARSE_PART_THICKNESS, which is the same sentence about a
1693
+ // part that is long and thin.
1627
1694
  let coarseIndex = levels.length - 1;
1628
- while (coarseIndex > 0 && (span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN) coarseIndex--;
1695
+ const thickness = Math.min(tw, th);
1696
+ while (
1697
+ coarseIndex > 0 &&
1698
+ ((span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN ||
1699
+ (thickness * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_THICKNESS)
1700
+ ) {
1701
+ coarseIndex--;
1702
+ }
1629
1703
  const coarse = levels[coarseIndex];
1630
1704
  const spanAtCoarse = (span * scaleReference) / coarse.reduction;
1631
1705
  const stride = Math.max(1, Math.round(spanAtCoarse * COARSE_STRIDE_FRACTION));
@@ -1635,7 +1709,7 @@ function placePart(
1635
1709
  for (const scale of scales) {
1636
1710
  const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
1637
1711
  grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
1638
- candidates.push(...localMinima(field, 2, MINIMA_PER_SCALE));
1712
+ candidates.push(...localMinima(field, COARSE_SUPPRESSION_CELLS, MINIMA_PER_SCALE));
1639
1713
  }
1640
1714
  base.coarse = grid;
1641
1715
  candidates.sort((a, b) => a.residual - b.residual);
package/src/render.ts CHANGED
@@ -336,6 +336,17 @@ export interface PoseOptions {
336
336
  * one instrument that does (`bonedist.ts`) needs it on every frame.
337
337
  */
338
338
  bones?: boolean;
339
+ /**
340
+ * Also record every slot's attachment geometry, whole — see `AttachmentPose`
341
+ * and `Frame.attachments` (issue #864). Off by default for `bones`' reason:
342
+ * nothing that draws reads it, and `render --geometry` is the one caller.
343
+ *
344
+ * ⭐ **It is not filtered by `slots`/`hidden` and not cut by a clip.** Those
345
+ * are statements about which pixels are drawn; the geometry is a statement
346
+ * about where the pose put each attachment, and it is the same pose whatever
347
+ * a picture of it leaves out.
348
+ */
349
+ geometry?: boolean;
339
350
  /**
340
351
  * Pose under this skin, by the name the skeleton declares for it.
341
352
  *
@@ -635,6 +646,44 @@ export interface Frame {
635
646
  * the two rigs.
636
647
  */
637
648
  bones?: BoneSnapshot[];
649
+ /**
650
+ * Every slot's attachment as the pose left it, in draw order — present only
651
+ * when `PoseOptions.geometry` asked for it (issue #864).
652
+ *
653
+ * ⚠️ Not `pieces` again. A piece is what gets DRAWN: `--slot`/`--hide` remove
654
+ * pieces and a clip replaces one with the clipper's own triangle list, whose
655
+ * vertices are not the attachment's and are not numbered like them. A
656
+ * consumer comparing a triangle's edges across frames needs vertex `i` to be
657
+ * the same vertex in every frame, so these are the attachment's own vertices,
658
+ * whole, for every slot that shows a region or a mesh.
659
+ *
660
+ * Read off the same skeleton at the same step as `pieces` and `bones`, which
661
+ * is what puts it on render's frame grid by construction rather than by a
662
+ * second derivation of it.
663
+ */
664
+ attachments?: AttachmentPose[];
665
+ }
666
+
667
+ /**
668
+ * One slot's region or mesh attachment in one posed frame (issue #864).
669
+ *
670
+ * `vertices` come from the runtime's own `computeWorldVertices` over the whole
671
+ * attachment — the call `pieceOf` makes, through the one helper both share —
672
+ * so skinning and deform live in spine-core and nowhere here.
673
+ */
674
+ export interface AttachmentPose {
675
+ slot: string;
676
+ /** The attachment's own name, which is what a deform or attachment timeline keys. */
677
+ attachment: string;
678
+ /**
679
+ * World positions, `x, y` per vertex, **y up**. A region's four corners are in
680
+ * spine-core's order — bottom-left, top-left, top-right, bottom-right — and a
681
+ * mesh's vertices in the attachment's own order, so index `i` names the same
682
+ * vertex in every frame.
683
+ */
684
+ vertices: number[];
685
+ /** Slot colour x attachment colour, straight alpha, 0..1 — the piece's `tint`, by the same arithmetic. */
686
+ color: [number, number, number, number];
638
687
  }
639
688
 
640
689
  /** Where the world sits in a frame: the four world numbers plus the scale. */
@@ -732,6 +781,7 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
732
781
  time: i * step,
733
782
  pieces: piecesOf(skeleton, pieceOpts),
734
783
  ...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
784
+ ...(opts?.geometry ? { attachments: attachmentsOf(skeleton) } : {}),
735
785
  });
736
786
  }
737
787
  return frames;
@@ -746,20 +796,31 @@ export function sampleAnimation(data: SkeletonData, name: string, fps: number, o
746
796
  * whole content is the setup pose.
747
797
  */
748
798
  export function sampleSetupPose(data: SkeletonData, opts?: PoseOptions): Frame[] {
749
- const skeleton = skeletonUnderSkin(data, opts?.skin);
750
- skeleton.setupPose();
751
- skeleton.update(0);
752
- skeleton.updateWorldTransform(Physics.reset);
799
+ const skeleton = setupPosed(data, opts?.skin);
753
800
  return [
754
801
  {
755
802
  index: 0,
756
803
  time: 0,
757
804
  pieces: piecesOf(skeleton, piecesOptions(opts)),
758
805
  ...(opts?.bones ? { bones: boneSnapshots(skeleton) } : {}),
806
+ ...(opts?.geometry ? { attachments: attachmentsOf(skeleton) } : {}),
759
807
  },
760
808
  ];
761
809
  }
762
810
 
811
+ /**
812
+ * A fresh skeleton under `skin`, stepped into its setup pose — the one recipe
813
+ * `sampleSetupPose` and the geometry export's `rest` table both pose from, so
814
+ * the two cannot come to describe different rest poses.
815
+ */
816
+ function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
817
+ const skeleton = skeletonUnderSkin(data, skin);
818
+ skeleton.setupPose();
819
+ skeleton.update(0);
820
+ skeleton.updateWorldTransform(Physics.reset);
821
+ return skeleton;
822
+ }
823
+
763
824
  /**
764
825
  * Every animation of one skeleton at one rate, keyed by the name its frames are
765
826
  * filed under. A skeleton with no animation at all contributes its setup pose
@@ -877,14 +938,7 @@ function pieceOf(
877
938
  'the attachment names a region the atlas does not have',
878
939
  );
879
940
  }
880
- const colour = pose.color;
881
- const own = attachment.color;
882
- const tint: [number, number, number, number] = [
883
- colour.r * own.r,
884
- colour.g * own.g,
885
- colour.b * own.b,
886
- colour.a * own.a,
887
- ];
941
+ const tint = tintOf(slot, attachment);
888
942
  // The dark colour is the SLOT's alone — an attachment has a `color` and no
889
943
  // dark one, so there is nothing to multiply it by. Read off `appliedPose`
890
944
  // like the light colour, so an `rgba2` timeline reaches the picture.
@@ -897,18 +951,11 @@ function pieceOf(
897
951
 
898
952
  let piece: Piece;
899
953
  let triangles: number[];
954
+ const world = worldVerticesOf(skeleton, slot, attachment);
900
955
  if (isMesh) {
901
- // `worldVerticesLength` is 2 per vertex whether or not the mesh is
902
- // weighted — the weight runs live in `vertices`, not here — so this is the
903
- // full output length and the whole mesh is computed in one call. Deform
904
- // offsets, if the pose carries any, are applied inside it.
905
- const world = new Array<number>(attachment.worldVerticesLength).fill(0);
906
- attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
907
956
  triangles = attachment.triangles;
908
957
  piece = { kind: 'mesh', ...common, texture, world, uvs, triangles };
909
958
  } else {
910
- const world = new Array<number>(8).fill(0);
911
- attachment.computeWorldVertices(slot, attachment.getOffsets(pose), world, 0, 2);
912
959
  triangles = QUAD_TRIANGLES;
913
960
  piece = { kind: 'region', ...common, texture, world, uvs };
914
961
  }
@@ -916,6 +963,56 @@ function pieceOf(
916
963
  return clippedPiece(piece, triangles, uvs, clipper);
917
964
  }
918
965
 
966
+ /** Slot colour x attachment colour, straight alpha — what a piece is tinted by and a geometry entry records. */
967
+ function tintOf(slot: Slot, attachment: MeshAttachment | RegionAttachment): [number, number, number, number] {
968
+ const colour = slot.appliedPose.color;
969
+ const own = attachment.color;
970
+ return [colour.r * own.r, colour.g * own.g, colour.b * own.b, colour.a * own.a];
971
+ }
972
+
973
+ /**
974
+ * One attachment's world vertices on `slot`, whole, by the runtime's own routine.
975
+ *
976
+ * The one place either kind is asked for them: `pieceOf` draws what this returns
977
+ * and `attachmentsOf` records it, so a drawn frame and its geometry export
978
+ * cannot disagree about where a vertex is.
979
+ *
980
+ * `worldVerticesLength` is 2 per vertex whether or not the mesh is weighted —
981
+ * the weight runs live in `vertices`, not here — so this is the full output
982
+ * length and the whole mesh is computed in one call. Deform offsets, if the
983
+ * slot's pose carries any, are applied inside it; a region's offsets are read
984
+ * for the sequence frame the slot's pose resolves.
985
+ */
986
+ function worldVerticesOf(skeleton: Skeleton, slot: Slot, attachment: MeshAttachment | RegionAttachment): number[] {
987
+ if (attachment instanceof MeshAttachment) {
988
+ const world = new Array<number>(attachment.worldVerticesLength).fill(0);
989
+ attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, world, 0, 2);
990
+ return world;
991
+ }
992
+ const world = new Array<number>(8).fill(0);
993
+ attachment.computeWorldVertices(slot, attachment.getOffsets(slot.appliedPose), world, 0, 2);
994
+ return world;
995
+ }
996
+
997
+ /**
998
+ * Every slot's region or mesh attachment, whole, in the posed draw order — see
999
+ * `Frame.attachments`. No subset and no clip: those are `piecesOf`'s business.
1000
+ */
1001
+ function attachmentsOf(skeleton: Skeleton): AttachmentPose[] {
1002
+ const out: AttachmentPose[] = [];
1003
+ for (const slot of skeleton.drawOrder.appliedPose) {
1004
+ const attachment = slot.appliedPose.attachment;
1005
+ if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
1006
+ out.push({
1007
+ slot: slot.data.name,
1008
+ attachment: attachment.name,
1009
+ vertices: worldVerticesOf(skeleton, slot, attachment),
1010
+ color: tintOf(slot, attachment),
1011
+ });
1012
+ }
1013
+ return out;
1014
+ }
1015
+
919
1016
  /**
920
1017
  * `piece` as the active clip leaves it, or `piece` itself when the clipper cut
921
1018
  * nothing — see `piecesOf`.
@@ -951,6 +1048,262 @@ function clippedPiece(piece: Piece, triangles: number[], uvs: Float32Array, clip
951
1048
  return { kind: 'mesh', tint, dark, slot, page, texture, world, uvs: clippedUvs, triangles: clipped };
952
1049
  }
953
1050
 
1051
+ // ---------------------------------------------------------------------------
1052
+ // the geometry export — `render --geometry` (issue #864)
1053
+ // ---------------------------------------------------------------------------
1054
+ //
1055
+ // ⭐ A frame set is pixels, and two judgements a consumer that does not link
1056
+ // spine-core wants to make are not about pixels: how far a mesh triangle is
1057
+ // stretched over its rest shape, and whether a region holds still in its own
1058
+ // bone's frame. Both need the numbers the pose was drawn FROM. So `render` writes
1059
+ // them beside the pictures, off the very `Frame`s it drew — one call, one frame
1060
+ // grid, one viewport — rather than a second command re-deriving any of the three.
1061
+
1062
+ /** The export's file name inside an animation's frame directory, and its format tag. */
1063
+ export const GEOMETRY_FILE = 'geometry.json';
1064
+ export const GEOMETRY_SPEC = 'rigc-geometry/1';
1065
+ /** Stated in the file, so a reader cannot take the numbers for frame pixels. */
1066
+ export const GEOMETRY_COORDINATES = 'spine world, y up, world units';
1067
+
1068
+ /** A geometry file's frame: `Frame` reduced to the numbers the export promises. */
1069
+ export interface GeometryFrame {
1070
+ index: number;
1071
+ time: number;
1072
+ bones: GeometryBone[];
1073
+ attachments: AttachmentPose[];
1074
+ }
1075
+
1076
+ /** One bone's world transform: `world = [a b; c d]·local + (worldX, worldY)`. */
1077
+ export interface GeometryBone {
1078
+ name: string;
1079
+ a: number;
1080
+ b: number;
1081
+ c: number;
1082
+ d: number;
1083
+ worldX: number;
1084
+ worldY: number;
1085
+ }
1086
+
1087
+ /**
1088
+ * One attachment's rest geometry and its topology — the half of a stretch ratio
1089
+ * no frame carries.
1090
+ *
1091
+ * ⭐ **Rest is the setup pose's bones with no deform**, taken for every
1092
+ * attachment any frame of the file shows — including one the setup pose does not
1093
+ * show, which a slot only swaps to later. For an attachment the setup pose does
1094
+ * show, these vertices are the `setup` entry's own, bit for bit: both come off
1095
+ * one skeleton posed by `setupPosed`.
1096
+ */
1097
+ export interface AttachmentRest {
1098
+ slot: string;
1099
+ attachment: string;
1100
+ kind: 'region' | 'mesh';
1101
+ vertices: number[];
1102
+ /** Vertex index triplets. A region's are the runtime's own `0 1 2 2 3 0`. */
1103
+ triangles: number[];
1104
+ /** A mesh's hull vertex count — the first `hull` vertices, as the format's `hull` field counts them. */
1105
+ hull?: number;
1106
+ /** A mesh's `uvs`, `u, v` per vertex over the untrimmed drawing, y down — the attachment's own, not a page's. */
1107
+ uvs?: number[];
1108
+ }
1109
+
1110
+ export interface GeometryFile {
1111
+ spec: string;
1112
+ coordinates: string;
1113
+ /** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
1114
+ animation: string | null;
1115
+ skin?: string;
1116
+ fps: number;
1117
+ /** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
1118
+ viewport: FramesSidecar['viewport'];
1119
+ /** Every bone in the skeleton's declaration order, and its parent's name. */
1120
+ bones: Array<{ name: string; parent: string | null }>;
1121
+ /**
1122
+ * Every slot in the skeleton's declaration order, and the bone it hangs from —
1123
+ * which is the bone whose frame "still in its own bone's frame" is read in.
1124
+ */
1125
+ slots: Array<{ name: string; bone: string }>;
1126
+ rest: AttachmentRest[];
1127
+ /** The setup pose, sampled the way `sampleSetupPose` samples it. */
1128
+ setup: Omit<GeometryFrame, 'index' | 'time'>;
1129
+ frames: GeometryFrame[];
1130
+ }
1131
+
1132
+ /**
1133
+ * A number the export could not print as a number — refused rather than written,
1134
+ * because `JSON.stringify` writes `NaN` and `Infinity` as `null` and a consumer
1135
+ * would read a hole in the geometry as a vertex at nothing.
1136
+ */
1137
+ export class GeometryError extends Error {}
1138
+
1139
+ /** `frames.json`'s viewport block for `v` — the one spelling both files use. */
1140
+ export function sidecarViewport(v: Viewport): FramesSidecar['viewport'] {
1141
+ return {
1142
+ x: v.minX,
1143
+ y: v.minY,
1144
+ width: v.maxX - v.minX,
1145
+ height: v.maxY - v.minY,
1146
+ scale: v.scale,
1147
+ pixelWidth: v.width,
1148
+ pixelHeight: v.height,
1149
+ };
1150
+ }
1151
+
1152
+ /**
1153
+ * The geometry file for one frame set — `frames` exactly as a sampler returned
1154
+ * them with `{ bones: true, geometry: true }`, which is what makes its grid the
1155
+ * frame set's own.
1156
+ *
1157
+ * Refused by `GeometryError`, naming the frame, the slot, the attachment and the
1158
+ * vertex (or the bone), when any number in it is not finite.
1159
+ */
1160
+ export function geometryFileOf(
1161
+ data: SkeletonData,
1162
+ animation: string | null,
1163
+ fps: number,
1164
+ frames: Frame[],
1165
+ viewport: Viewport,
1166
+ skin: string | undefined,
1167
+ ): GeometryFile {
1168
+ const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
1169
+ if (frame.bones === undefined || frame.attachments === undefined) {
1170
+ throw new Error(`${where} was sampled without { bones: true, geometry: true }; the geometry export needs both`);
1171
+ }
1172
+ return {
1173
+ bones: frame.bones.map(({ name, a, b, c, d, worldX, worldY }) => ({ name, a, b, c, d, worldX, worldY })),
1174
+ attachments: frame.attachments,
1175
+ };
1176
+ };
1177
+ const posed = frames.map((frame) => ({
1178
+ index: frame.index,
1179
+ time: frame.time,
1180
+ ...geometryFrame(frame, `frame ${frame.index}`),
1181
+ }));
1182
+ const setupFrame = sampleSetupPose(data, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
1183
+ const setup = geometryFrame(setupFrame, 'the setup pose');
1184
+ const file: GeometryFile = {
1185
+ spec: GEOMETRY_SPEC,
1186
+ coordinates: GEOMETRY_COORDINATES,
1187
+ animation,
1188
+ ...(skin === undefined ? {} : { skin }),
1189
+ fps,
1190
+ viewport: sidecarViewport(viewport),
1191
+ bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent?.name ?? null })),
1192
+ slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
1193
+ rest: restOf(data, skin, [setup.attachments, ...posed.map((frame) => frame.attachments)]),
1194
+ setup,
1195
+ frames: posed,
1196
+ };
1197
+ refuseNonFinite(file);
1198
+ return file;
1199
+ }
1200
+
1201
+ /**
1202
+ * The rest table: every (slot, attachment) the given frames show, in order of
1203
+ * first appearance, posed on one setup skeleton with the slot's deform empty.
1204
+ *
1205
+ * The attachment is resolved the way the pose resolved it — `Skeleton.getAttachment`,
1206
+ * the skin first and the default skin second — so a name means the object the
1207
+ * frames drew. A region's rest corners are read for the sequence frame the setup
1208
+ * pose resolves, which is the frame its setup pose draws.
1209
+ */
1210
+ function restOf(data: SkeletonData, skin: string | undefined, shown: AttachmentPose[][]): AttachmentRest[] {
1211
+ const skeleton = setupPosed(data, skin);
1212
+ // Slot, then attachment: two maps rather than one joined key, so no pair of
1213
+ // names can fold into another's entry whatever characters they carry.
1214
+ const seen = new Map<string, Set<string>>();
1215
+ const out: AttachmentRest[] = [];
1216
+ for (const entries of shown) {
1217
+ for (const entry of entries) {
1218
+ const names = seen.get(entry.slot) ?? new Set<string>();
1219
+ if (names.has(entry.attachment)) continue;
1220
+ names.add(entry.attachment);
1221
+ seen.set(entry.slot, names);
1222
+ const slotIndex = data.findSlot(entry.slot)?.index ?? -1;
1223
+ const slot = skeleton.slots[slotIndex];
1224
+ const attachment = slot === undefined ? null : skeleton.getAttachment(slotIndex, entry.attachment);
1225
+ if (slot === undefined || !(attachment instanceof MeshAttachment || attachment instanceof RegionAttachment)) {
1226
+ throw new Error(
1227
+ `slot ${JSON.stringify(entry.slot)} showed attachment ${JSON.stringify(entry.attachment)} in a frame, and ` +
1228
+ 'the setup skeleton resolves no region or mesh of that name there',
1229
+ );
1230
+ }
1231
+ // A mesh reads the slot's deform array; the setup pose leaves it empty,
1232
+ // and emptying it here says so rather than trusting that it is.
1233
+ slot.appliedPose.deform.length = 0;
1234
+ const vertices = worldVerticesOf(skeleton, slot, attachment);
1235
+ out.push(
1236
+ attachment instanceof MeshAttachment
1237
+ ? {
1238
+ slot: entry.slot,
1239
+ attachment: entry.attachment,
1240
+ kind: 'mesh',
1241
+ vertices,
1242
+ triangles: Array.from(attachment.triangles),
1243
+ hull: attachment.hullLength / 2,
1244
+ uvs: Array.from(attachment.regionUVs),
1245
+ }
1246
+ : { slot: entry.slot, attachment: entry.attachment, kind: 'region', vertices, triangles: [...QUAD_TRIANGLES] },
1247
+ );
1248
+ }
1249
+ }
1250
+ return out;
1251
+ }
1252
+
1253
+ /** Throw a `GeometryError` at the first number in `file` that is not finite, naming where it sits. */
1254
+ function refuseNonFinite(file: GeometryFile): void {
1255
+ const check = (where: string, entries: AttachmentPose[] | AttachmentRest[], bones: GeometryBone[]): void => {
1256
+ for (const bone of bones) {
1257
+ for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY'] as const) {
1258
+ if (!Number.isFinite(bone[field])) {
1259
+ throw new GeometryError(`${where}: bone ${JSON.stringify(bone.name)} has ${field} ${String(bone[field])}; a world transform is finite`);
1260
+ }
1261
+ }
1262
+ }
1263
+ for (const entry of entries) {
1264
+ const bad = entry.vertices.findIndex((value) => !Number.isFinite(value));
1265
+ if (bad === -1) continue;
1266
+ throw new GeometryError(
1267
+ `${where}: slot ${JSON.stringify(entry.slot)} attachment ${JSON.stringify(entry.attachment)} vertex ` +
1268
+ `${Math.floor(bad / 2)} has ${bad % 2 === 0 ? 'x' : 'y'} ${String(entry.vertices[bad])}; a posed vertex is finite`,
1269
+ );
1270
+ }
1271
+ };
1272
+ check('the rest table', file.rest, []);
1273
+ check('the setup pose', file.setup.attachments, file.setup.bones);
1274
+ for (const frame of file.frames) check(`frame ${frame.index}`, frame.attachments, frame.bones);
1275
+ }
1276
+
1277
+ /**
1278
+ * The file's text: a JSON object whose header fields sit one per line and whose
1279
+ * `rest` entries and `frames` sit one per line each, so a diff of two exports
1280
+ * names the frame that moved.
1281
+ *
1282
+ * Numbers are `JSON.stringify`'s, which is how every JSON this tree writes prints
1283
+ * them — the shortest decimal that reads back as the same double, fixed by the
1284
+ * language rather than a locale — so a vertex in the file IS the runtime's
1285
+ * vertex, not a rounding of it.
1286
+ */
1287
+ export function geometryText(file: GeometryFile): string {
1288
+ const lines: string[] = [];
1289
+ const entries = Object.entries(file);
1290
+ entries.forEach(([key, value], i) => {
1291
+ const comma = i + 1 < entries.length ? ',' : '';
1292
+ if ((key === 'rest' || key === 'frames') && Array.isArray(value)) {
1293
+ if (value.length === 0) {
1294
+ lines.push(` ${JSON.stringify(key)}: []${comma}`);
1295
+ return;
1296
+ }
1297
+ lines.push(` ${JSON.stringify(key)}: [`);
1298
+ value.forEach((item, j) => lines.push(` ${JSON.stringify(item)}${j + 1 < value.length ? ',' : ''}`));
1299
+ lines.push(` ]${comma}`);
1300
+ return;
1301
+ }
1302
+ lines.push(` ${JSON.stringify(key)}: ${JSON.stringify(value)}${comma}`);
1303
+ });
1304
+ return `{\n${lines.join('\n')}\n}\n`;
1305
+ }
1306
+
954
1307
  // ---------------------------------------------------------------------------
955
1308
  // texture-only substitution — see `substituteTexture`
956
1309
  // ---------------------------------------------------------------------------
@@ -1347,7 +1700,9 @@ export function framingViewport(data: SkeletonData, maxSide: number, opts?: Pose
1347
1700
  // A clip is taken off for the same reason (issue #844): what it removes still
1348
1701
  // counts toward the box, so adding or keying a mask moves no pixel it leaves
1349
1702
  // drawn — see `PoseOptions.unclipped`.
1350
- const { slots: _drawn, hidden: _hidden, ...whole } = opts ?? {};
1703
+ // Neither the bone snapshots nor the geometry export frame anything, and
1704
+ // both would be taken at `FRAMING_FPS` for every animation only to be dropped.
1705
+ const { slots: _drawn, hidden: _hidden, bones: _bones, geometry: _geometry, ...whole } = opts ?? {};
1351
1706
  const framed: PoseOptions = { ...whole, unclipped: true };
1352
1707
  const sets =
1353
1708
  data.animations.length === 0