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 +15 -1
- package/cli.ts +198 -0
- package/docs/AUTHORING.md +182 -6
- package/docs/INGEST.md +7 -0
- package/docs/MOTION.md +3 -0
- package/package.json +1 -1
- package/src/chainfit.ts +2028 -0
- package/src/pose.ts +58 -32
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`
|
|
156
|
-
| `--scale` |
|
|
157
|
-
| `--rotation` |
|
|
158
|
-
| `--max-residual` | `pose`
|
|
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.
|
|
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": {
|