spine-rigc 0.10.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,25 @@ 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).
388
+
389
+ ## The gallery — four complete rigs over art that ships with them
390
+
391
+ Each directory in [`gallery/`](https://github.com/firejune/rigc/tree/main/gallery) is
392
+ one rig spec, one motion spec and the PNGs they name, small enough to read in one
393
+ sitting. Each stars a single feature, so *how do I do X* has a working answer rather
394
+ than a field table, and each README carries the frame rate it was authored at, what
395
+ was verified, and what writing it cost. Repository material: a clone and
396
+ `bun install` runs them.
397
+
398
+ | Example | Stars | What it is |
399
+ | --- | --- | --- |
400
+ | [`gallery/walk`](https://github.com/firejune/rigc/tree/main/gallery/walk) | `ik` constraints + **`ik` timelines** | Two two-bone leg chains solved to foot targets — the planted leg nailed down, the swinging one let go at the top of its lift |
401
+ | [`gallery/squash`](https://github.com/firejune/rigc/tree/main/gallery/squash) | **`deform` timelines** | A ball squashed about its contact point and stretched along its travel, from two affine transforms written out in the README |
402
+ | [`gallery/flex`](https://github.com/firejune/rigc/tree/main/gallery/flex) | **`contour` meshes** | A swallow-tailed banner and a serrated leaf: four meshes traced off their own alpha, waved by bone timelines and rippled by a `deform` |
403
+ | [`gallery/ride`](https://github.com/firejune/rigc/tree/main/gallery/ride) | `path` attachments + **path constraints** | A trolley coasting down a drawn rail and rolling back, driven by a `position` timeline, with `groups` + `stagger` keying the wheels and the ears |
375
404
 
376
405
  ## Commands
377
406
 
@@ -390,6 +419,7 @@ commands take it and what its default is.
390
419
  | `preview --candidate <dir>` | one self-contained `.html` that plays it |
391
420
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
392
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 |
393
423
  | `diff <candidate.json> <reference.json>` | structural comparison of two skeletons, one ratio per measure and deliberately no combined score |
394
424
  | `check --candidate <dir> --frames <dir>` | the candidate against reference pictures — the only instrument here that can see a *wrong animation* |
395
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,
@@ -382,10 +394,41 @@ const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> =
382
394
  authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
383
395
  };
384
396
 
385
- /** What a contour mesh measured about its own fit, or nothing for the others. */
397
+ /**
398
+ * What a mesh measured about its own fit against the art it names, or nothing
399
+ * for a mesh with no art to measure against.
400
+ *
401
+ * Printed for authored geometry as well as for a `contour` (issue #277): the
402
+ * figure is a measurement between the emitted triangles and the PNG, so it means
403
+ * the same thing whoever drew the vertices, and the silence was the defect —
404
+ * an octagon rim placed on a round part's silhouette clips its own ink outline
405
+ * at 94.31% and used to print nothing at all.
406
+ *
407
+ * The hole is appended only when there is one, so the common line is unchanged.
408
+ * It is the one figure in the report that a hole moves: `coverage` and
409
+ * `overshoot` are both measured against the FILLED silhouette, so spanning an
410
+ * interior hole is neither missing coverage nor reaching past anything, and an
411
+ * unintentional hole — a gap in the art, a stroke that failed to join — bought
412
+ * fill over transparent pixels with nothing anywhere saying so (issue #275).
413
+ */
386
414
  function meshFit(m: CompileResult['meshes'][number]): string {
387
415
  if (m.coverage === undefined) return '';
388
- return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it`;
416
+ const hole = m.holePixels ? `, enclosing ${m.holePixels}px of hole` : '';
417
+ return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${hole}`;
418
+ }
419
+
420
+ /**
421
+ * The triangle budget a `MESH` line is read against: the rig's, or nothing.
422
+ *
423
+ * 📐 It used to be the literal `80`, which was nobody's budget — the rig quoted
424
+ * in issue #275 declared 64, `A13_MESH_BUDGET` measured against that 64
425
+ * correctly, and the line an author actually reads printed 80. Under the default
426
+ * `--profile spine` `A13` is `PROF`, so the printed number is the only budget
427
+ * figure in the output and it has to be the declared one. A rig that declares
428
+ * none says so in the same words `A13` SKIPs in, rather than being given a wall.
429
+ */
430
+ function meshBudget(rig: CompileResult['rig']): string {
431
+ return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
389
432
  }
390
433
 
391
434
  /**
@@ -471,7 +514,7 @@ function cmdBuild(flags: Record<string, string>): void {
471
514
  for (const m of result.meshes) {
472
515
  console.log(
473
516
  ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
474
- `(budget 80) bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}`,
517
+ `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}`,
475
518
  );
476
519
  }
477
520
  for (const ph of result.physics) {
@@ -1029,6 +1072,100 @@ function cmdPose(flags: Record<string, string>): void {
1029
1072
  writeJson(out, report);
1030
1073
  }
1031
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
+
1032
1169
  // ---------------------------------------------------------------------------
1033
1170
  // choosing between results — vote
1034
1171
  // ---------------------------------------------------------------------------
@@ -1763,7 +1900,8 @@ function cmdExplain(flags: Record<string, string>): void {
1763
1900
  for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
1764
1901
  for (const m of result.meshes) {
1765
1902
  console.log(
1766
- ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles bones=[${m.bones.join(', ')}]${meshFit(m)}`,
1903
+ ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
1904
+ `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}`,
1767
1905
  );
1768
1906
  }
1769
1907
  }
@@ -1838,6 +1976,27 @@ const FLAG_MEANINGS: Record<string, string> = {
1838
1976
  'max-residual':
1839
1977
  `above this residual a placement is refused by name instead of reported flat (default ${DEFAULT_MAX_RESIDUAL}); ` +
1840
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)`,
1841
2000
  animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
1842
2001
  max: 'longest side of a rendered frame, in pixels (default 256)',
1843
2002
  record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
@@ -1876,6 +2035,12 @@ const FLAG_VALUES: Record<string, string> = {
1876
2035
  scale: '<min,max>',
1877
2036
  rotation: '<min,max>',
1878
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>',
1879
2044
  animation: '<name>',
1880
2045
  max: '<px>',
1881
2046
  record: '<result.json>',
@@ -2012,6 +2177,50 @@ const COMMANDS: CommandDoc[] = [
2012
2177
  },
2013
2178
  },
2014
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
+ },
2015
2224
  {
2016
2225
  name: 'vote',
2017
2226
  usage: [
@@ -2099,6 +2308,19 @@ const USAGE = [
2099
2308
  'canvas cannot contain and a part whose rotation is a free degree of freedom are each',
2100
2309
  'named as such. See `rigc pose --help`.',
2101
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
+ '',
2102
2324
  'vote is the same page with two to four builds in it and an answer coming back:',
2103
2325
  ' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
2104
2326
  ' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
@@ -2145,6 +2367,7 @@ try {
2145
2367
  else if (command === 'render') cmdRender(flags);
2146
2368
  else if (command === 'preview') cmdPreview(flags);
2147
2369
  else if (command === 'pose') cmdPose(flags);
2370
+ else if (command === 'chainfit') cmdChainFit(flags);
2148
2371
  else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
2149
2372
  } catch (err) {
2150
2373
  if (err instanceof UsageError) {
@@ -2176,5 +2399,12 @@ try {
2176
2399
  console.error(`rigc pose: ${err.message}`);
2177
2400
  process.exit(2);
2178
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
+ }
2179
2409
  throw err;
2180
2410
  }