spine-rigc 0.11.0 → 0.12.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
@@ -36,6 +36,7 @@ parser and a list of named assertions all come back green.
36
36
  | a compiled rig | `rigc preview` | one self-contained `.html` that plays it in Spine's own web player |
37
37
  | two to four compiled rigs | `rigc vote` | one ballot page a human picks from, and the answer checked into a ledger |
38
38
  | a picture of a key pose | `rigc pose` | where each loose part PNG sits in it, in spec coordinates — the movement between two poses is then yours to key ([docs/MOTION.md](docs/MOTION.md)) |
39
+ | the same picture, and a rig | `rigc chainfit` | the parts `pose` refuses because something is drawn over them — read through the candidate's own draw order and hierarchy, with the share of each part the answer was measured on |
39
40
 
40
41
  Everything in that table needs Bun and this package: no clone, no reference art, no
41
42
  art pipeline, no server.
@@ -353,6 +354,16 @@ self-contained `.html` that plays it in Spine's own web player; **`vote`** puts
353
354
  four candidates in one page and takes a human's answer back. Reach for them the moment
354
355
  a rig compiles green, because green says nothing at all about the picture.
355
356
 
357
+ <p align="center">
358
+ <img src="https://raw.githubusercontent.com/firejune/rigc/main/assets/rigc-keypose.gif" alt="Two key poses read back by rigc pose, two candidate in-between motions compiled from them, and a rigc vote ballot picking one" width="600" />
359
+ </p>
360
+
361
+ <p align="center"><em>The whole loop on one character: two key poses are the given
362
+ conditions, <code>rigc pose</code> reads where every part sits in each picture, two
363
+ candidate in-betweenings are compiled from the same pair — and a real
364
+ <code>rigc vote</code> ballot picks the winner, because the movement between the poses
365
+ is the one thing no instrument here will grade.</em></p>
366
+
356
367
  Three properties of `vote` are worth stating, because they are what make its ledger
357
368
  usable by the next agent rather than by a reader: **a tie is a recorded outcome, not a
358
369
  missing one** — `both-unacceptable` is the tie that means *propose again*, and it is
@@ -371,7 +382,9 @@ it, so those coordinates go into the rig and the motion **by construction** and
371
382
  effort goes into the part no instrument can measure: the movement between two poses.
372
383
  A part that matches nowhere is refused by name, two near-equal placements are reported
373
384
  as both, and nothing it prints is a score. Fields, the coordinate contract and the
374
- limits: [AUTHORING.md §11](docs/AUTHORING.md).
385
+ limits: [AUTHORING.md §11](docs/AUTHORING.md). The parts it refuses because
386
+ something is drawn over them are `rigc chainfit`'s, once a candidate exists —
387
+ [§12](docs/AUTHORING.md).
375
388
 
376
389
  ## The gallery — four complete rigs over art that ships with them
377
390
 
@@ -406,6 +419,7 @@ commands take it and what its default is.
406
419
  | `preview --candidate <dir>` | one self-contained `.html` that plays it |
407
420
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
408
421
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
422
+ | `chainfit --candidate <dir> --images <dir> --frame <png>` | reads the parts `pose` refuses, through the candidate's own draw order and hierarchy: masked residuals over **visible** pixels, one hinge per child instead of four degrees of freedom, and the `rotate` key value each answer implies |
409
423
  | `diff <candidate.json> <reference.json>` | structural comparison of two skeletons, one ratio per measure and deliberately no combined score |
410
424
  | `check --candidate <dir> --frames <dir>` | the candidate against reference pictures — the only instrument here that can see a *wrong animation* |
411
425
  | `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
package/cli.ts CHANGED
@@ -72,6 +72,18 @@ import {
72
72
  poseLines,
73
73
  type PoseOptions,
74
74
  } from './src/pose.ts';
75
+ import {
76
+ ANCHOR_MAX_RESIDUAL,
77
+ ANCHOR_MAX_UNEXPLAINED,
78
+ chainFitLines,
79
+ ChainFitError,
80
+ DEFAULT_HINGE_MAX,
81
+ DEFAULT_HINGE_MIN,
82
+ DEFAULT_MIN_VISIBLE,
83
+ DEFAULT_PASSES,
84
+ estimateChainFit,
85
+ type ChainFitOptions,
86
+ } from './src/chainfit.ts';
75
87
  import { buildPreview, PLAYER_LINE, type PreviewPage } from './src/preview.ts';
76
88
  import {
77
89
  atlasPageNames,
@@ -1060,6 +1072,100 @@ function cmdPose(flags: Record<string, string>): void {
1060
1072
  writeJson(out, report);
1061
1073
  }
1062
1074
 
1075
+ // ---------------------------------------------------------------------------
1076
+ // reading the half a picture hides — chainfit
1077
+ // ---------------------------------------------------------------------------
1078
+ //
1079
+ // ⭐ `pose` above reads a picture with nothing but the loose parts, and refuses
1080
+ // the parts another part is drawn over — a residual measured through an occluder
1081
+ // rises AT the correct placement, so the honest answer is a refusal. This reads
1082
+ // those, and the whole difference is that it is also given the CANDIDATE: with a
1083
+ // draw order the covered pixels can be excluded from a part's objective instead
1084
+ // of charged to it, and with a hierarchy a child of a placed bone has one degree
1085
+ // of freedom — the hinge about its own pivot — where `pose` has four.
1086
+ //
1087
+ // 🚫 Same phase and the same framing as `pose`: it reads a given condition into
1088
+ // spec coordinates and grades nothing. Every residual is a trust signal, every
1089
+ // threshold is reported, and `visibleShare` is how much of the part the number
1090
+ // was even computed on.
1091
+ //
1092
+ // rigc chainfit --candidate <dir> --images <dir> --frame poseA.png [--anchor pose.json]
1093
+
1094
+ const DEFAULT_CHAINFIT_OUT = 'chainfit.json';
1095
+
1096
+ function cmdChainFit(flags: Record<string, string>): void {
1097
+ if (flags.candidate === undefined) {
1098
+ throw new UsageError('chainfit needs --candidate <dir | skeleton.json> — the compiled rig to read the frame through');
1099
+ }
1100
+ if (flags.images === undefined) {
1101
+ throw new UsageError("chainfit needs --images <dir> — where the candidate's attachment image names resolve to PNGs");
1102
+ }
1103
+ if (flags.frame === undefined) throw new UsageError('chainfit needs --frame <path> — one pose frame to read the placements out of');
1104
+ // Refused rather than ignored. Every other --candidate command takes --atlas, so
1105
+ // passing it here is a reasonable thing to try — and a flag that silently does
1106
+ // nothing is worse than one that says why it cannot.
1107
+ if (flags.atlas !== undefined) {
1108
+ throw new UsageError(
1109
+ 'chainfit reads no atlas: the part art comes from --images, one PNG per attachment image name, and the ' +
1110
+ 'skeleton is all it needs of the candidate. Drop --atlas',
1111
+ );
1112
+ }
1113
+ const options: ChainFitOptions = {
1114
+ candidatePath: flags.candidate,
1115
+ imagesDir: flags.images,
1116
+ framePath: flags.frame,
1117
+ };
1118
+ if (flags.anchor !== undefined) options.anchorPath = flags.anchor;
1119
+ const hinge = readRange(flags, 'hinge');
1120
+ if (hinge) {
1121
+ if (hinge.high - hinge.low > 360) throw new UsageError('--hinge cannot span more than a full turn');
1122
+ options.hinge = { minDeg: hinge.low, maxDeg: hinge.high };
1123
+ }
1124
+ if (flags.stretch !== undefined) {
1125
+ const value = Number(flags.stretch);
1126
+ if (!Number.isFinite(value) || value < 1) throw new UsageError('--stretch must be a ratio of 1 or more, e.g. 1.25');
1127
+ options.stretch = value;
1128
+ }
1129
+ if (flags['min-visible'] !== undefined) {
1130
+ const value = Number(flags['min-visible']);
1131
+ if (!Number.isFinite(value) || value < 0 || value > 1) throw new UsageError('--min-visible must be a number in [0, 1]');
1132
+ options.minVisible = value;
1133
+ }
1134
+ if (flags['max-residual'] !== undefined) {
1135
+ const value = Number(flags['max-residual']);
1136
+ if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--max-residual must be a number in (0, 1]');
1137
+ options.maxResidual = value;
1138
+ }
1139
+ if (flags.passes !== undefined) {
1140
+ const value = Number(flags.passes);
1141
+ if (!Number.isInteger(value) || value < 1 || value > 8) throw new UsageError('--passes must be a whole number in 1..8');
1142
+ options.passes = value;
1143
+ }
1144
+ if (flags['anchor-residual'] !== undefined) {
1145
+ const value = Number(flags['anchor-residual']);
1146
+ if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--anchor-residual must be a number in (0, 1]');
1147
+ options.anchorMaxResidual = value;
1148
+ }
1149
+ const scale = readRange(flags, 'scale');
1150
+ if (scale) {
1151
+ if (scale.low <= 0) throw new UsageError('--scale minimum must be greater than zero');
1152
+ options.scale = { min: scale.low, max: scale.high };
1153
+ }
1154
+ const rotation = readRange(flags, 'rotation');
1155
+ if (rotation) {
1156
+ if (rotation.high - rotation.low > 360) throw new UsageError('--rotation cannot span more than a full turn');
1157
+ options.rotation = { minDeg: rotation.low, maxDeg: rotation.high };
1158
+ }
1159
+
1160
+ console.log('rigc chainfit');
1161
+ const report = estimateChainFit(options);
1162
+ for (const line of chainFitLines(report)) console.log(line);
1163
+
1164
+ const target = resolve(flags.out ?? DEFAULT_CHAINFIT_OUT);
1165
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_CHAINFIT_OUT) : target;
1166
+ writeJson(out, report);
1167
+ }
1168
+
1063
1169
  // ---------------------------------------------------------------------------
1064
1170
  // choosing between results — vote
1065
1171
  // ---------------------------------------------------------------------------
@@ -1870,6 +1976,27 @@ const FLAG_MEANINGS: Record<string, string> = {
1870
1976
  'max-residual':
1871
1977
  `above this residual a placement is refused by name instead of reported flat (default ${DEFAULT_MAX_RESIDUAL}); ` +
1872
1978
  'it is a reporting threshold, not a pass bar',
1979
+ anchor:
1980
+ 'a `rigc pose` report for THIS frame, whose confident placements become the anchors the chains hang off ' +
1981
+ '(default: run that pass internally over exactly the parts the candidate draws)',
1982
+ hinge:
1983
+ `the window each child bone's local rotation is searched over, in Spine degrees about its setup value ` +
1984
+ `(default \`${DEFAULT_HINGE_MIN},${DEFAULT_HINGE_MAX}\`, a full turn — one degree of freedom is cheap enough not ` +
1985
+ 'to risk a window that does not contain the truth)',
1986
+ stretch:
1987
+ 'also search a uniform scale on every bone, over this ratio either way (e.g. 1.25). Without it the stretch ' +
1988
+ "degree of freedom is searched only where the candidate's own animations key a `scale` timeline, because a rig " +
1989
+ 'that never scales a bone is a rig saying that bone does not stretch',
1990
+ 'min-visible':
1991
+ `below this share of a part surviving the parts drawn over it, the placement is refused by name instead of ` +
1992
+ `reported flat (default ${DEFAULT_MIN_VISIBLE}); the best one found is still printed, and it is a reporting ` +
1993
+ 'threshold, not a pass bar',
1994
+ passes:
1995
+ `how many times the occluder masks are rebuilt from the answers and the fit rerun (default ${DEFAULT_PASSES}); ` +
1996
+ "pass 1 freezes each part's visible set where the RIG predicts it, later passes where the last one landed",
1997
+ 'anchor-residual':
1998
+ `the residual a \`pose\` placement must be within to anchor a chain (default ${ANCHOR_MAX_RESIDUAL}, with ` +
1999
+ `unexplained ≤ ${ANCHOR_MAX_UNEXPLAINED} and unambiguous — the 2026-09-03 measurement run's own clean-frame criterion)`,
1873
2000
  animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
1874
2001
  max: 'longest side of a rendered frame, in pixels (default 256)',
1875
2002
  record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
@@ -1908,6 +2035,12 @@ const FLAG_VALUES: Record<string, string> = {
1908
2035
  scale: '<min,max>',
1909
2036
  rotation: '<min,max>',
1910
2037
  'max-residual': '<0..1>',
2038
+ anchor: '<pose.json>',
2039
+ hinge: '<min,max>',
2040
+ stretch: '<ratio>',
2041
+ 'min-visible': '<0..1>',
2042
+ passes: '<n>',
2043
+ 'anchor-residual': '<0..1>',
1911
2044
  animation: '<name>',
1912
2045
  max: '<px>',
1913
2046
  record: '<result.json>',
@@ -2044,6 +2177,50 @@ const COMMANDS: CommandDoc[] = [
2044
2177
  },
2045
2178
  },
2046
2179
  },
2180
+ {
2181
+ name: 'chainfit',
2182
+ usage: [
2183
+ `rigc chainfit --candidate <dir | skeleton.json> --images <dir> --frame <path> [--anchor pose.json] [--out ${DEFAULT_CHAINFIT_OUT}]`,
2184
+ ],
2185
+ flags: [
2186
+ 'candidate',
2187
+ 'images',
2188
+ 'frame',
2189
+ 'anchor',
2190
+ 'hinge',
2191
+ 'stretch',
2192
+ 'min-visible',
2193
+ 'max-residual',
2194
+ 'passes',
2195
+ 'anchor-residual',
2196
+ 'scale',
2197
+ 'rotation',
2198
+ 'out',
2199
+ ],
2200
+ overrides: {
2201
+ images: {
2202
+ value: '<dir>',
2203
+ meaning:
2204
+ "where each attachment's image name resolves to a loose PNG. ⚠️ NOT a part list the way `pose --images` " +
2205
+ 'is one — the candidate decides what the parts are, so extra PNGs in here are simply unused and a name ' +
2206
+ 'the directory lacks is refused by name',
2207
+ },
2208
+ scale: {
2209
+ meaning:
2210
+ 'the scale window the INTERNAL anchor pass searches, as frame pixels per part pixel (default ' +
2211
+ `\`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`). Refused together with --anchor, which means there is no internal pass`,
2212
+ },
2213
+ rotation: {
2214
+ meaning:
2215
+ 'the rotation window the INTERNAL anchor pass searches, in screen degrees (default `-180,180`). Refused ' +
2216
+ 'together with --anchor — the chains\' own window is --hinge',
2217
+ },
2218
+ out: {
2219
+ value: '<file>',
2220
+ meaning: `the .json report to write (default \`${DEFAULT_CHAINFIT_OUT}\`); a directory means "the default name in here"`,
2221
+ },
2222
+ },
2223
+ },
2047
2224
  {
2048
2225
  name: 'vote',
2049
2226
  usage: [
@@ -2131,6 +2308,19 @@ const USAGE = [
2131
2308
  'canvas cannot contain and a part whose rotation is a free degree of freedom are each',
2132
2309
  'named as such. See `rigc pose --help`.',
2133
2310
  '',
2311
+ 'chainfit reads the half of that picture pose refuses. It is the same question with',
2312
+ 'one more input — the candidate rig — and that input buys two things: draw order, so',
2313
+ 'the pixels another part covers are EXCLUDED from a part\'s residual instead of',
2314
+ 'charged to it, and hierarchy, so a child of a placed bone is searched over one hinge',
2315
+ 'instead of four degrees of freedom:',
2316
+ ' rigc chainfit --candidate build/ --images parts/ --frame poseA.png',
2317
+ 'Every residual is over the part\'s VISIBLE pixels and comes with the `visibleShare` it',
2318
+ 'was computed on, so a mostly-hidden answer carries its own uncertainty. It grades',
2319
+ 'nothing either: a part too far behind the others is refused by the visibility floor,',
2320
+ 'a limb with no trusted part on it or above it is refused `no-anchor`, and two hinge',
2321
+ 'answers that explain the picture equally well are both reported. See',
2322
+ '`rigc chainfit --help`.',
2323
+ '',
2134
2324
  'vote is the same page with two to four builds in it and an answer coming back:',
2135
2325
  ' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
2136
2326
  ' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
@@ -2177,6 +2367,7 @@ try {
2177
2367
  else if (command === 'render') cmdRender(flags);
2178
2368
  else if (command === 'preview') cmdPreview(flags);
2179
2369
  else if (command === 'pose') cmdPose(flags);
2370
+ else if (command === 'chainfit') cmdChainFit(flags);
2180
2371
  else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
2181
2372
  } catch (err) {
2182
2373
  if (err instanceof UsageError) {
@@ -2208,5 +2399,12 @@ try {
2208
2399
  console.error(`rigc pose: ${err.message}`);
2209
2400
  process.exit(2);
2210
2401
  }
2402
+ // Same kind as a PoseError, and printed the same way for the same reason: the
2403
+ // messages name a path, a bone or an attachment, and reprinting the whole
2404
+ // usage under them buries the one line that says what to change.
2405
+ if (err instanceof ChainFitError) {
2406
+ console.error(`rigc chainfit: ${err.message}`);
2407
+ process.exit(2);
2408
+ }
2211
2409
  throw err;
2212
2410
  }
package/docs/AUTHORING.md CHANGED
@@ -81,6 +81,14 @@ bun install # once
81
81
  bun cli.ts pose --images path/to/parts --frame path/to/poseA.png --out poseA.json
82
82
  # ↳ one entry per part PNG: where it sits, how confident that is, and where two
83
83
  # answers are equally good — §11
84
+ #
85
+ # …and once a candidate EXISTS, the half of that picture pose refuses — the parts
86
+ # another part is drawn over — is readable through the rig's own draw order and
87
+ # hierarchy. This one runs after a first build, not before it:
88
+ bun cli.ts chainfit --candidate path/to/spine --images path/to/parts \
89
+ --frame path/to/poseA.png --out chainfitA.json
90
+ # ↳ one entry per drawn slot: where it sits, over how much of it that was
91
+ # measured, and the `rotate` key value it implies — §12
84
92
 
85
93
  bun cli.ts build \
86
94
  --rig path/to/my.rig.json \
@@ -143,19 +151,25 @@ What the flags mean:
143
151
  | `--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** |
144
152
  | `--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** |
145
153
  | `--atlas-in` | `build` only: 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:`; the atlas is re-emitted into `--out`, re-anchored — **§0.2** |
146
- | `--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 |
154
+ | `--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) |
147
155
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
148
156
  | `--profile` | `spine` = the 22 validity rules (**the default**) · `spine-html` = all 36, opt-in |
149
- | `--candidate` | `check`, `bench`, `render`, `preview` 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` is the one command that takes it more than once** — repeat it 2–4 times, one per pane, labelled A, B, C, D in the order given; everywhere else a repeat is a typo and is refused |
157
+ | `--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` is the one command that takes it more than once** — repeat it 2–4 times, one per pane, labelled A, B, C, D in the order given; everywhere else a repeat is a typo and is refused |
150
158
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview`, 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`, so is a name that only *some* candidates have |
151
159
  | `--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` |
152
160
  | `--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 |
153
161
  | `--ledger` | `vote --record` only: the append-only JSONL the vote lands in (default `votes.jsonl`), one vote per line |
154
162
  | `--again` | `vote --record` only: record a second vote on a ballot the ledger already has. Without it a repeat is refused by name rather than doubled |
155
- | `--frame` | `pose` only: one pose frame — the picture to read part placements out of. One frame per call; several key poses are several calls, and correlating them is yours (§11) |
156
- | `--scale` | `pose` only: the scale window to search, as **frame pixels per part pixel**, `<min>,<max>` (default `0.5,2`). The report states what it searched, and a window that does not contain the truth does not reliably refuse — §11 |
157
- | `--rotation` | `pose` only: the rotation window to search, in screen degrees, `<min>,<max>` (default `-180,180`, a full turn). Narrow it when you know the art is upright |
158
- | `--max-residual` | `pose` only: above this residual a placement is **refused by name** instead of reported flat (default `0.25`). A reporting threshold, not a pass bar — the placement is still in the JSON |
163
+ | `--frame` | `pose` and `chainfit`: one pose frame — the picture to read part placements out of. One frame per call; several key poses are several calls, and correlating them is yours (§11) |
164
+ | `--scale` | the scale window to search, as **frame pixels per part pixel**, `<min>,<max>` (default `0.5,2`). The report states what it searched, and a window that does not contain the truth does not reliably refuse — §11. For `chainfit` it sizes the **internal anchor pass** and is refused beside `--anchor` — §12.4 |
165
+ | `--rotation` | the rotation window to search, in screen degrees, `<min>,<max>` (default `-180,180`, a full turn). Narrow it when you know the art is upright. For `chainfit`, again the internal anchor pass — the chains' own window is `--hinge` |
166
+ | `--max-residual` | `pose` and `chainfit`: above this residual a placement is **refused by name** instead of reported flat (default `0.25`). A reporting threshold, not a pass bar — the placement is still in the JSON |
167
+ | `--anchor` | `chainfit` only: a `rigc pose` report for **this** frame, whose confident placements become the anchors the chains hang off. Without it that pass runs internally — §12.2 |
168
+ | `--hinge` | `chainfit` only: the window each child bone's local rotation is searched over, in **Spine** degrees about its setup value, `<min>,<max>` (default `-180,180`) — §12.4 |
169
+ | `--stretch` | `chainfit` only: also search a uniform bone scale, this ratio either way. Without it, stretch is free only where the candidate's own animations key a `scale` timeline — §12.4 |
170
+ | `--min-visible` | `chainfit` only: below this share of a part surviving the parts drawn over it, the placement is refused `occluded` instead of reported flat (default `0.25`) — §12.4 |
171
+ | `--passes` | `chainfit` only: how many times the occluder masks are rebuilt from the answers and the fit rerun (default `2`) — §12.4 |
172
+ | `--anchor-residual` | `chainfit` only: the residual a `pose` placement must be within to anchor a chain (default `0.16`) — §12.2 |
159
173
 
160
174
  `render` also takes `--fps <n>` (the rate it samples at, default 12 — the same
161
175
  protocol rate the reference frames use) and `--max <px>` (the long side of a
@@ -366,6 +380,17 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
366
380
  a given condition, not a target, and the residual it reports is how far to trust
367
381
  a placement rather than how good the placement is. Reach for it the moment a
368
382
  request arrives as *"make it go from this picture to this one"*. **§11.**
383
+ - 🧩 **`chainfit` is that same reading of an input, through a rig you already
384
+ have.** `pose` is handed nothing but the parts and the picture, so on a dense
385
+ figure it refuses the half another part is drawn over — the residual it would
386
+ report there is measuring the occluder. Give the same frame a **candidate** and
387
+ two things become available that `pose` structurally cannot have: a draw order,
388
+ so the pixels a later part covers are taken OUT of the objective instead of
389
+ charged to it, and a hierarchy, so a child of a placed bone is searched over one
390
+ hinge about its own pivot rather than over four degrees of freedom. It reports the
391
+ `rotate` key value each answer implies, and the share of the part that answer was
392
+ actually measured on. Reach for it after a first build, when the frame still has
393
+ parts in it you cannot read. **§12.**
369
394
  - 🗳️ **`vote` is `preview` with more than one candidate in it and an answer
370
395
  coming back**, and it is the one step of this loop you cannot run yourself.
371
396
  Reach for it where the instruments have run out: two builds that `check` and
@@ -3871,3 +3896,154 @@ and threshold that was applied), and `caveats`.
3871
3896
  - **One frame per call.** Several key poses are several calls, and correlating A
3872
3897
  with B — which placement of a repeated part belongs to which limb, across two
3873
3898
  frames — is the authoring job, not this tool's.
3899
+
3900
+ ## 12. Reading the half of that picture `pose` refuses — `rigc chainfit`
3901
+
3902
+ ```bash
3903
+ bun cli.ts chainfit --candidate path/to/spine --images path/to/parts \
3904
+ --frame path/to/poseA.png [--anchor poseA.json] [--out chainfit.json]
3905
+ ```
3906
+
3907
+ §11 ends on a limitation it is honest about and cannot fix: *"a part drawn behind
3908
+ another has the occluder's pixels where its own should be, so its residual rises
3909
+ **at the correct placement**"*. On a ball that costs nothing. On a figure it costs
3910
+ half the figure — the far arm behind the torso, both thighs behind the torso and
3911
+ each other, the feet, the fists, whatever the hands hold.
3912
+
3913
+ This is the same question with **one more input: your candidate rig**. That input
3914
+ is what makes the difference measurable rather than a caveat, and it buys exactly
3915
+ two things.
3916
+
3917
+ - **Draw order**, so occlusion can be taken out of the arithmetic instead of
3918
+ apologised for. The parts drawn after a part are what covers it, and the pixels
3919
+ they cover are **excluded** from that part's objective rather than charged to it.
3920
+ Every residual here is over the part's **visible** pixels, and every one comes
3921
+ with the `visibleShare` it was computed on.
3922
+ - **Hierarchy and attachment geometry**, so the search collapses. A child bone
3923
+ whose parent is already placed does not have four degrees of freedom: the rig
3924
+ fixes its pivot, so what is left is **one hinge** about that pivot — plus a
3925
+ stretch, and only where your own timelines say the rig leaves scale free. One
3926
+ degree of freedom is also what removes the ambiguity §11 has to report: two
3927
+ identical limbs stop being two equal answers once each of them hangs off a
3928
+ different placed shoulder.
3929
+
3930
+ ### 12.1 Which one to reach for
3931
+
3932
+ | The question | The command |
3933
+ | --- | --- |
3934
+ | *"Where does each of these loose PNGs sit in this picture?"* — you have parts and a picture and nothing else | **`pose`** (§11) |
3935
+ | *"Where does this rig's own `rear-upper-arm` sit in this picture?"* — you already have a compiled candidate | **`chainfit`** |
3936
+ | A part that is big, distinctive and **unoccluded** | either; they agree, and `pose` needs no rig |
3937
+ | A part another part is drawn over | **`chainfit`** — this is the whole reason it exists |
3938
+ | A part that appears **twice** (two arms, two shins) | **`chainfit`**, if the two hang off different bones. `pose` is right to call it ambiguous and cannot do better |
3939
+ | You have **no rig yet** | **`pose`**. There is nothing to chain from, and inventing one would be a rig you then have to un-invent |
3940
+ | Your rig's joint offsets are **guesses** | **`pose` first.** Every hinge here turns about a pivot your rig declares; a wrong joint moves every answer below it. `pivotDisagreementPx` is what says so |
3941
+
3942
+ The normal order is **`pose` → author → `chainfit`**: read what you can with no
3943
+ rig, write a first rig from it, then read the rest of the frame through that rig.
3944
+ And `chainfit` runs `pose` for you unless you hand it one — see `--anchor`.
3945
+
3946
+ ### 12.2 The anchor, and why the walk only goes outward
3947
+
3948
+ A chain needs a trunk. `chainfit` takes its anchors from a **`rigc pose` report
3949
+ for the same frame** — `--anchor poseA.json` — and without one it runs that pass
3950
+ internally, over exactly the parts your candidate draws. A part becomes an anchor
3951
+ when `pose` came back **unambiguous** with `residual ≤ 0.16` and
3952
+ `unexplained ≤ 0.45`; those two numbers are `--anchor-residual` and a reported
3953
+ field, and they are the 2026-09-03 measurement run's own *clean frame* criterion
3954
+ rather than a line invented here.
3955
+
3956
+ An anchored part fixes its **whole bone** — four numbers read off the picture for
3957
+ the four a similarity has — and every descendant then follows from the rig. A bone
3958
+ **above** an anchor does not: recovering it would need to know what the link
3959
+ between them did, and that is precisely the unknown the anchor does not carry.
3960
+
3961
+ ⚠️ **So a limb with no trusted part on it or above it is refused `no-anchor`**, not
3962
+ guessed at from a cousin. If a whole side of your figure comes back that way, the
3963
+ repair is upstream: give `pose` a better frame, pin its `--scale`, or loosen
3964
+ `--anchor-residual` deliberately and read the consequences.
3965
+
3966
+ ### 12.3 What the report adds to a `pose` report
3967
+
3968
+ The coordinate contract is **identical** to §11.2 — frame pixels, y down, origin
3969
+ top-left, `(x, y)` where the part image's own centre lands, `rotationDeg` in screen
3970
+ degrees — so the two reports are readable side by side. On top of that, per
3971
+ placement:
3972
+
3973
+ | Field | Meaning |
3974
+ | --- | --- |
3975
+ | `residual` | the same objective as §11, over the part's **visible** pixels only: covered pixels are dropped from both sums rather than charged. **Not the same number as `pose`'s on an occluded part**, and never to be read without the next field |
3976
+ | `visibleShare` | the share of the part's own alpha weight the residual was computed on. A low residual on a `0.08` share is a confident statement about a sliver |
3977
+ | `scoredPixels` | how many part pixels that share actually is |
3978
+ | `visibleShareAtFit` | the share recomputed **where the answer landed**, rather than where the visible set was frozen. Far from `visibleShare` means the fit moved out of its own measurement; `--passes` is the repair |
3979
+ | `hingeDeg` | ⭐ the searched degree of freedom, in **Spine** degrees relative to the bone's setup rotation — **the value a `rotate` key would carry**. `null` on an anchor whose own parent is unplaced, where the quantity does not exist |
3980
+ | `localRotationDeg` | the bone's local rotation this implies, Spine degrees. The other half of the same answer |
3981
+ | `stretch` | the uniform scale on the bone; `1` where that DOF was not free |
3982
+ | `unexplained`, `offCanvas`, `footprint`, `bbox` | as §11.3, with `residual` and `unexplained` over the visible set and `offCanvas` over the whole part |
3983
+
3984
+ And per part:
3985
+
3986
+ | Field | Meaning |
3987
+ | --- | --- |
3988
+ | `role` | `anchor` (taken from the anchor pass, not re-fitted), `chain` (fitted through the rig), `unplaced` |
3989
+ | — | ⭐ **A refused ANCHOR is not a contradiction, and it is the most useful row in the table.** The anchor pass judged that placement over the part's *whole* footprint — all `pose` can see, and blind to what covers it — while this instrument has just measured how much of the part is visible at all. Both readings are true. A refused anchor means *the placement may well be right and the confirmation is missing*, and every part whose `anchoredTo` names that bone rests on it. Measured on the 2026-09-03 corpus, `rear-bracer` clears `pose`'s criterion on 81 of 147 frames at a median visible share of **0.1%** — suppressing the refusal there was tried and prints that as READ |
3990
+ | — | The **other** parts on an anchored bone are refused on their own numbers too, and there they mean something different again: their placement is the **rig's** prediction from that anchor, so their residual is a measurement of the rig (a goggle plate that will not sit on the head it is parented to shows up exactly here) |
3991
+ | `bone` | the bone this hangs off: its `parent`, its `setupRotationDeg`, its `depth` in links from the anchor, `anchoredTo`, the `dof` searched, the `window` taken, the other parts `sharedWith` it on that bone, and `carriedBones` |
3992
+ | `bone.dof.pivotFree` | your candidate keys a `translate` timeline on this bone, so the arc this answer sits on has a centre the rig itself moves. The placement is still read off pixels; `localRotationDeg` alone will not reproduce it |
3993
+ | `bone.carriedBones` | bones between the anchor and here that carry nothing scoreable. Their hinge could not be fitted, their setup rotation was carried through, and every number below them inherits that |
3994
+ | `bone.pivotDisagreementPx` | anchored bones only: how far the chain's own prediction of this bone's pivot is from where the anchor put it. **This is the one direct measurement of your rig against the picture** — a large value says the joint offset you declared is not the joint the frame shows |
3995
+ | `anchorVerdict` | what the anchor pass made of this same part: `residual`, `unexplained`, `ambiguous`, `eligible`. ⭐ `eligible: false` beside a `chain` placement is **a part the chain bought** |
3996
+ | `refusal` | `{ reason, detail }` or `null`. Reasons: `occluded`, `no-match`, `no-anchor`, `empty-part`, `no-part-image`, `unsupported-geometry` |
3997
+
3998
+ `⚠️ --images is not a part list here.` For `pose` every `.png` in the directory is
3999
+ a part; for `chainfit` **the candidate decides what the parts are** and the
4000
+ directory is only where each attachment's image name resolves. Extra PNGs in it are
4001
+ simply unused; a name the directory lacks is refused `no-part-image` by name.
4002
+
4003
+ ### 12.4 The flags that steer it
4004
+
4005
+ | Flag | What it does |
4006
+ | --- | --- |
4007
+ | `--anchor <pose.json>` | use this `rigc pose` report instead of running one. Refused together with `--scale` / `--rotation`, which size the internal pass that then does not happen |
4008
+ | `--atlas` | **refused by name.** Every other `--candidate` command takes it, so trying it here is reasonable — but the part art comes from `--images` and the skeleton is all this needs of the candidate, so a flag that silently did nothing would be worse than one that says why |
4009
+ | `--hinge <min,max>` | the window each child's local rotation is searched over, in Spine degrees about its setup value. Default `-180,180` — **a full turn, on purpose**: one degree of freedom is cheap enough to sweep exhaustively, and §11.4's warning about a window that does not contain the truth applies here too |
4010
+ | `--stretch <ratio>` | also search a uniform bone scale, this ratio either way. Without it, stretch is searched **only where your own animations key a `scale` timeline on that bone** — a rig that never scales a bone is a rig saying that bone does not stretch |
4011
+ | `--min-visible <0..1>` | below this visible share a placement is refused `occluded` instead of reported flat (default `0.25`). A reporting threshold, not a pass bar; the placement is still in the JSON |
4012
+ | `--max-residual <0..1>` | as §11, over the visible pixels (default `0.25`) |
4013
+ | `--passes <n>` | how many times the masks are rebuilt from the answers and the fit rerun (default `2`) |
4014
+ | `--anchor-residual <0..1>` | the residual a `pose` placement must be within to anchor (default `0.16`) |
4015
+ | `--scale`, `--rotation` | passed to the **internal anchor pass**, meaning exactly what they mean to `pose` |
4016
+
4017
+ ### 12.5 What it cannot see — read this before using the numbers
4018
+
4019
+ - 🚨 **The occlusion is your candidate's, and so is the geometry.** A wrong draw
4020
+ order masks the wrong pixels. A joint offset the rig gets wrong moves the pivot
4021
+ every hinge below it turns about. An answer here is only as good as the structure
4022
+ it was read through, and that is a different failure mode from anything in §11 —
4023
+ `pose` can be wrong about a part, `chainfit` can be wrong about a *limb*, in a
4024
+ way that looks internally consistent. `pivotDisagreementPx` and
4025
+ `bone.carriedBones` are the two fields that expose it.
4026
+ - ⚠️ **`residual` here and `residual` in a `pose` report are not the same
4027
+ measurement on an occluded part**, by construction: one drops the covered pixels
4028
+ and the other charges them. Do not put them in one column. What *is* comparable
4029
+ is each against its own `visibleShare` / `unexplained`.
4030
+ - ⚠️ **Setup draw order, on one frame.** A `drawOrder` timeline reorders your slots
4031
+ at runtime and this cannot know the time, so a candidate that has one is masked in
4032
+ the order its setup pose declares. The report says so in `caveats` when it finds
4033
+ one.
4034
+ - ⚠️ **The hinge is searched; the pivot is not.** Nothing here searches a bone's
4035
+ translation, so a bone you key `translate` on is reported `pivotFree` rather than
4036
+ solved.
4037
+ - **A constraint moves bones after their local transforms compose.** With IK,
4038
+ transform, path or physics constraints in the candidate, a fitted
4039
+ `localRotationDeg` is still a placement but not necessarily a value you can key
4040
+ and reproduce. The report lists the constraints it found.
4041
+ - **Shear, a non-uniform scale and any `inherit` but `normal`** are refused
4042
+ `unsupported-geometry` by bone, and a non-region attachment by attachment.
4043
+ Composing through them would put a plausible number on geometry this instrument
4044
+ does not model — the same shape of wrongness as a search window that excludes the
4045
+ truth.
4046
+ - **Nothing here grades anything either.** Same phase as §11, same reason: the pose
4047
+ is a given condition and once your spec states it there is nothing left to be
4048
+ close to. Every threshold in the report is a reporting threshold. There is no pass
4049
+ bar and `caveats` says so in the file.
package/docs/INGEST.md CHANGED
@@ -1069,6 +1069,13 @@ rigc pose --images examples/3-timing-and-spacing/images \
1069
1069
  PNG where you expected several is the tell, and the `.. parts` line prints the
1070
1070
  count. AUTHORING §11.4 is the rest of what that command cannot see.
1071
1071
 
1072
+ ⭐ **And on ingest work you usually have the thing `pose` is missing.** A skeleton you
1073
+ are transcribing IS a compiled candidate, so the parts `pose` refuses because
1074
+ something is drawn over them are readable through its own draw order and hierarchy —
1075
+ `rigc chainfit --candidate <that skeleton> --images <dir> --frame <png>`, AUTHORING
1076
+ §12. It is the natural second pass here: `pose` reads the trunk of a foreign figure,
1077
+ `chainfit` reads the limbs it hides, and both report placements rather than grades.
1078
+
1072
1079
  📎 To be exact about what is missing: rigc *can* lift a region's drawing back off a
1073
1080
  page — `extractRegion` does it, and the contour mesh generator uses it under
1074
1081
  `--atlas-in` — so what is absent is a **command**, not the capability. It refuses by
package/docs/MOTION.md CHANGED
@@ -22,6 +22,9 @@ toolchain and this page does not invent one.
22
22
  - What the Spine editor does when nobody tells it otherwise: **AUTHORING §10**
23
23
  - Reading a pose out of a picture — the instrument this recipe consumes:
24
24
  **AUTHORING §11**
25
+ - The parts of that picture `pose` refuses because something is drawn over them,
26
+ once a first candidate exists: **AUTHORING §12** (`rigc chainfit`). It reports the
27
+ `rotate` key value each answer implies, which is the form this recipe wants them in
25
28
  - If what you were handed is a **compiled skeleton** rather than loose parts — reading
26
29
  it, transcribing it into specs, re-pivoting it, extending it with an animation:
27
30
  [INGEST.md](INGEST.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.11.0",
3
+ "version": "0.12.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {