spine-rigc 0.6.0 → 0.8.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/cli.ts CHANGED
@@ -31,14 +31,38 @@
31
31
  * Its paths resolve against the cuts.json file itself, so the table travels
32
32
  * with the project that owns the art rather than with this repository.
33
33
  */
34
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
34
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
35
35
  import { basename, dirname, join, resolve } from 'node:path';
36
+ import {
37
+ BallotError,
38
+ buildBallot,
39
+ ledgerLineText,
40
+ MAX_CANDIDATES,
41
+ MIN_CANDIDATES,
42
+ parseLedger,
43
+ readBallotManifest,
44
+ resultFilename,
45
+ TIE,
46
+ verifyResult,
47
+ VOTE_RULES,
48
+ type BallotCandidateInput,
49
+ type BallotInput,
50
+ } from './src/ballot.ts';
36
51
  import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
37
52
  import { compile, CompileError, type CompileOptions } from './src/compile.ts';
38
53
  import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
39
54
  import { copyAtlasImages } from './src/emit.ts';
40
55
  import { parseJsonWithPosition } from './src/json-position.ts';
41
56
  import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
57
+ import {
58
+ DEFAULT_MAX_RESIDUAL,
59
+ DEFAULT_SCALE_MAX,
60
+ DEFAULT_SCALE_MIN,
61
+ estimatePose,
62
+ PoseError,
63
+ poseLines,
64
+ type PoseOptions,
65
+ } from './src/pose.ts';
42
66
  import { buildPreview, PLAYER_LINE, type PreviewPage } from './src/preview.ts';
43
67
  import {
44
68
  atlasPageNames,
@@ -128,31 +152,63 @@ function repositoryUrl(): string {
128
152
  * flag": inferring it would turn `--out --json report.json` — a real typo, a
129
153
  * missing value — into a silently accepted switch plus a stray positional.
130
154
  */
131
- const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images']);
155
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again']);
156
+
157
+ /**
158
+ * The flags a command is allowed to spell more than once.
159
+ *
160
+ * Only `vote --candidate` is, because a ballot is *by definition* several
161
+ * candidates. Everywhere else a repeat is a mistake and is refused: `check
162
+ * --candidate a --candidate b` used to take `b` silently, which is a report
163
+ * about a rig the caller did not think they were asking about.
164
+ */
165
+ const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
166
+ vote: new Set(['candidate']),
167
+ };
132
168
 
133
- /** `--flag value` pairs plus the leftover positionals, in order. */
134
- function parseArgs(argv: string[]): { flags: Record<string, string>; positional: string[] } {
169
+ /**
170
+ * `--flag value` pairs plus the leftover positionals, in order.
171
+ *
172
+ * `lists` carries every occurrence of every flag and `flags` carries the last
173
+ * one, so a command that wants a repeated flag reads `lists` and the ones that
174
+ * do not are untouched by the addition.
175
+ */
176
+ function parseArgs(
177
+ argv: string[],
178
+ repeatable: ReadonlySet<string> = new Set(),
179
+ ): { flags: Record<string, string>; lists: Record<string, string[]>; positional: string[] } {
135
180
  const flags: Record<string, string> = {};
181
+ const lists: Record<string, string[]> = {};
136
182
  const positional: string[] = [];
183
+ const take = (name: string, value: string): void => {
184
+ if (flags[name] !== undefined && !repeatable.has(name)) {
185
+ throw new UsageError(
186
+ `--${name} was given more than once (${JSON.stringify(flags[name])} then ${JSON.stringify(value)}); ` +
187
+ 'this command takes it once',
188
+ );
189
+ }
190
+ flags[name] = value;
191
+ (lists[name] ??= []).push(value);
192
+ };
137
193
  for (let i = 0; i < argv.length; i++) {
138
194
  const arg = argv[i];
139
195
  if (arg.startsWith('--')) {
140
196
  const eq = arg.indexOf('=');
141
197
  if (eq !== -1) {
142
- flags[arg.slice(2, eq)] = arg.slice(eq + 1);
198
+ take(arg.slice(2, eq), arg.slice(eq + 1));
143
199
  } else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
144
- flags[arg.slice(2)] = 'true';
200
+ take(arg.slice(2), 'true');
145
201
  } else {
146
202
  const next = argv[i + 1];
147
203
  if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
148
- flags[arg.slice(2)] = next;
204
+ take(arg.slice(2), next);
149
205
  i++;
150
206
  }
151
207
  } else {
152
208
  positional.push(arg);
153
209
  }
154
210
  }
155
- return { flags, positional };
211
+ return { flags, lists, positional };
156
212
  }
157
213
 
158
214
  /**
@@ -726,6 +782,288 @@ function cmdPreview(flags: Record<string, string>): void {
726
782
  console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
727
783
  }
728
784
 
785
+ // ---------------------------------------------------------------------------
786
+ // reading a given condition — pose
787
+ // ---------------------------------------------------------------------------
788
+ //
789
+ // ⭐ Every other command here takes a spec and looks at what came out. This one
790
+ // runs the other way: it takes a PICTURE the user already has — a key pose — and
791
+ // reads spec coordinates out of it, so an agent can state those poses in a rig and
792
+ // a motion by construction and spend its loops on the part nobody can measure, the
793
+ // movement between them.
794
+ //
795
+ // 🚫 It grades nothing, and the distinction is load-bearing rather than modest.
796
+ // `check` and `bench` compare a build against a reference and their numbers mean
797
+ // "how close"; a pose frame is not a reference, it is an INPUT, and once the spec
798
+ // states it there is nothing left to be close to. So the residual here is a trust
799
+ // signal — how much of the frame this placement actually explains — and the only
800
+ // threshold in `src/pose.ts` is the one that decides whether to print an answer at
801
+ // all, which the caller can move.
802
+ //
803
+ // rigc pose --images parts/ --frame poseA.png [--out pose.json]
804
+
805
+ const DEFAULT_POSE_OUT = 'pose.json';
806
+
807
+ /** `--scale 0.5,2` / `--rotation -30,30` — a pair of numbers, low first. */
808
+ function readRange(flags: Record<string, string>, key: string): { low: number; high: number } | undefined {
809
+ const raw = flags[key];
810
+ if (raw === undefined) return undefined;
811
+ const parts = raw.split(',').map((s) => Number(s.trim()));
812
+ if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n))) {
813
+ throw new UsageError(`--${key} takes two numbers: <min>,<max>`);
814
+ }
815
+ if (parts[1] < parts[0]) throw new UsageError(`--${key} ${JSON.stringify(raw)}: the minimum must not exceed the maximum`);
816
+ return { low: parts[0], high: parts[1] };
817
+ }
818
+
819
+ function cmdPose(flags: Record<string, string>): void {
820
+ if (flags.images === undefined) throw new UsageError('pose needs --images <dir> — the directory the loose part PNGs are in');
821
+ if (flags.frame === undefined) throw new UsageError('pose needs --frame <path> — one pose frame to read the placements out of');
822
+ const options: PoseOptions = { imagesDir: flags.images, framePath: flags.frame };
823
+ const scale = readRange(flags, 'scale');
824
+ if (scale) {
825
+ if (scale.low <= 0) throw new UsageError('--scale minimum must be greater than zero');
826
+ options.scale = { min: scale.low, max: scale.high };
827
+ }
828
+ const rotation = readRange(flags, 'rotation');
829
+ if (rotation) {
830
+ if (rotation.high - rotation.low > 360) throw new UsageError('--rotation cannot span more than a full turn');
831
+ options.rotation = { minDeg: rotation.low, maxDeg: rotation.high };
832
+ }
833
+ if (flags['max-residual'] !== undefined) {
834
+ const value = Number(flags['max-residual']);
835
+ if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--max-residual must be a number in (0, 1]');
836
+ options.maxResidual = value;
837
+ }
838
+
839
+ console.log('rigc pose');
840
+ const report = estimatePose(options);
841
+ for (const line of poseLines(report)) console.log(line);
842
+
843
+ // Same `--out` shape as `preview` and `vote`: one file, and a directory means
844
+ // "the default name in here" rather than a report written over a directory.
845
+ const target = resolve(flags.out ?? DEFAULT_POSE_OUT);
846
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_POSE_OUT) : target;
847
+ writeJson(out, report);
848
+ }
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // choosing between results — vote
852
+ // ---------------------------------------------------------------------------
853
+ //
854
+ // ⭐ `preview` shows one candidate; this shows two to four of them side by side
855
+ // and takes an answer back. The rest of this toolchain is instruments, and it
856
+ // should be — the vote opens only where the instruments have already run out.
857
+ // See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
858
+ // why the labels are A and B, and why the record is hashes.
859
+ //
860
+ // Two modes on one command, because they share exactly one thing and it is the
861
+ // contract between them: the ballot manifest. Splitting them would document
862
+ // that format twice and let the halves drift.
863
+ //
864
+ // rigc vote --candidate <a> --candidate <b> [--animation <n>] [--out ballot.html]
865
+ // rigc vote --record <result.json> [--ballot ballot.html] [--ledger votes.jsonl] [--again]
866
+
867
+ const DEFAULT_BALLOT = 'ballot.html';
868
+ const DEFAULT_LEDGER = 'votes.jsonl';
869
+
870
+ /** Load one candidate off disk in the shape a ballot needs. */
871
+ function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
872
+ const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
873
+ for (const path of [skeletonPath, atlasPath]) {
874
+ if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
875
+ }
876
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
877
+ const atlasText = readFileSync(atlasPath, 'utf8');
878
+ const atlasDir = dirname(atlasPath);
879
+ const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
880
+ const path = join(atlasDir, name);
881
+ if (!existsSync(path)) {
882
+ throw new UsageError(
883
+ `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
884
+ 'a page a ballot cannot embed is a page the player could not have loaded either',
885
+ );
886
+ }
887
+ return { name, bytes: readFileSync(path) };
888
+ });
889
+ return {
890
+ candidate: { source: skeletonPath, skeletonText, atlasText, pages },
891
+ animations: skeletonAnimationNames(skeletonText, skeletonPath),
892
+ };
893
+ }
894
+
895
+ /**
896
+ * The one animation every candidate plays.
897
+ *
898
+ * ⚠️ Refused rather than resolved per candidate. Two panes running two
899
+ * different animations look like a comparison and are not one, and a voter has
900
+ * no way to see that it happened — the labels are `A` and `B`, which is the
901
+ * whole point, so nothing on the screen would say so.
902
+ */
903
+ function commonAnimation(
904
+ flags: Record<string, string>,
905
+ loaded: { animations: string[] }[],
906
+ ): string | null {
907
+ const asked = flags.animation;
908
+ if (asked === undefined) {
909
+ const first = loaded[0].animations[0];
910
+ if (first === undefined) {
911
+ const withAny = loaded.findIndex((l) => l.animations.length > 0);
912
+ if (withAny !== -1) {
913
+ throw new UsageError(
914
+ `candidate ${withAny + 1} has animations [${loaded[withAny].animations.join(', ')}] and candidate 1 has none — ` +
915
+ 'a ballot plays one animation in every pane, so there is nothing to compare here',
916
+ );
917
+ }
918
+ return null;
919
+ }
920
+ const missing = loaded.findIndex((l) => !l.animations.includes(first));
921
+ if (missing !== -1) {
922
+ throw new UsageError(
923
+ `the default animation is candidate 1's first, ${JSON.stringify(first)}, and candidate ${missing + 1} does not ` +
924
+ `have it (it has [${loaded[missing].animations.join(', ') || 'none'}]); name one they share with --animation`,
925
+ );
926
+ }
927
+ return first;
928
+ }
929
+ const missing = loaded.findIndex((l) => !l.animations.includes(asked));
930
+ if (missing !== -1) {
931
+ throw new UsageError(
932
+ `no animation ${JSON.stringify(asked)} in candidate ${missing + 1}; it has ` +
933
+ `[${loaded[missing].animations.join(', ') || 'none'}]`,
934
+ );
935
+ }
936
+ return asked;
937
+ }
938
+
939
+ /** vote (ballot mode) — write the page a human opens. */
940
+ function cmdVoteBallot(flags: Record<string, string>, candidates: string[]): void {
941
+ if (candidates.length < MIN_CANDIDATES) {
942
+ throw new UsageError(
943
+ `a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} --candidate <dir | skeleton.json>, and ${candidates.length} ` +
944
+ 'was given — one candidate on its own is `rigc preview`',
945
+ );
946
+ }
947
+ if (candidates.length > MAX_CANDIDATES) {
948
+ throw new UsageError(
949
+ `${candidates.length} candidates were given and a ballot holds at most ${MAX_CANDIDATES} — they go side by side ` +
950
+ 'on one screen, and a comparison that needs scrolling is not a comparison',
951
+ );
952
+ }
953
+ // `--atlas` names ONE atlas and there are several skeletons here, so there is
954
+ // no unambiguous thing it could mean. Each candidate's atlas has to sit beside
955
+ // its skeleton, which is what `build --out` leaves behind.
956
+ if (flags.atlas !== undefined) {
957
+ throw new UsageError(
958
+ '--atlas names one atlas and a ballot has several candidates; each one\'s atlas has to sit beside its skeleton',
959
+ );
960
+ }
961
+
962
+ const loaded = candidates.map((target) => loadBallotCandidate(target));
963
+ const animation = commonAnimation(flags, loaded);
964
+
965
+ const target = resolve(flags.out ?? DEFAULT_BALLOT);
966
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_BALLOT) : target;
967
+
968
+ const input: BallotInput = {
969
+ candidates: loaded.map((l) => l.candidate),
970
+ animation,
971
+ version: readVersion(),
972
+ };
973
+ const { html, manifest } = buildBallot(input);
974
+
975
+ console.log('rigc vote');
976
+ console.log(` .. ballot ${manifest.ballot}`);
977
+ console.log(` .. animation ${animation === null ? '(none — the setup pose)' : animation}`);
978
+ for (let i = 0; i < manifest.candidates.length; i++) {
979
+ const entry = manifest.candidates[i];
980
+ const bytes = loaded[i].candidate.pages.reduce((n, p) => n + p.bytes.length, 0);
981
+ console.log(
982
+ ` .. ${entry.label} ${entry.digest.slice(0, 'sha256:'.length + 12)}… ` +
983
+ `${entry.pages.length} page(s), ${(bytes / 1024).toFixed(1)} KiB <- ${entry.source}`,
984
+ );
985
+ }
986
+ mkdirSync(dirname(out), { recursive: true });
987
+ writeFileSync(out, html);
988
+ console.log(
989
+ ` .. the page shows ${manifest.candidates.map((c) => c.label).join('/')} and nothing else — the paths above are ` +
990
+ 'in its manifest, never on the screen',
991
+ );
992
+ console.log(
993
+ ` .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from ` +
994
+ `unpkg (@${PLAYER_LINE}), so the first open needs a network`,
995
+ );
996
+ console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
997
+ console.log(
998
+ `rigc: then record the saved vote with rigc vote --record ${resultFilename(manifest.ballot)} --ballot ${out}`,
999
+ );
1000
+ }
1001
+
1002
+ /** vote (record mode) — check one saved vote and append it to the ledger. */
1003
+ function cmdVoteRecord(flags: Record<string, string>): void {
1004
+ for (const key of ['candidate', 'out'] as const) {
1005
+ if (flags[key] !== undefined) {
1006
+ throw new UsageError(`--record and --${key} are the two halves of this command; run them one at a time`);
1007
+ }
1008
+ }
1009
+ const resultPath = resolve(flags.record);
1010
+ const ballotPath = resolve(flags.ballot ?? DEFAULT_BALLOT);
1011
+ const ledgerPath = resolve(flags.ledger ?? DEFAULT_LEDGER);
1012
+ for (const [what, path] of [
1013
+ ['result', resultPath],
1014
+ ['ballot', ballotPath],
1015
+ ] as const) {
1016
+ if (!existsSync(path)) {
1017
+ throw new UsageError(
1018
+ `no ${what} file at ${path}` + (what === 'ballot' ? ' — name the page this vote came from with --ballot' : ''),
1019
+ );
1020
+ }
1021
+ }
1022
+
1023
+ console.log('rigc vote --record');
1024
+ console.log(` .. result ${resultPath}`);
1025
+ console.log(` .. ballot ${ballotPath}`);
1026
+ console.log(` .. ledger ${ledgerPath}`);
1027
+
1028
+ const manifest = readBallotManifest(readFileSync(ballotPath, 'utf8'), ballotPath);
1029
+ const result = readJsonFile(resultPath);
1030
+ const existing = existsSync(ledgerPath) ? parseLedger(readFileSync(ledgerPath, 'utf8'), ledgerPath) : [];
1031
+ const attempts = existing.filter((l) => l.ballot === manifest.ballot).length;
1032
+ const again = flags.again !== undefined;
1033
+
1034
+ const { refusals, line } = verifyResult(manifest, result, { attempts, again });
1035
+ if (line === null) {
1036
+ for (const refusal of refusals) console.error(` FAIL ${refusal.rule}: ${refusal.detail}`);
1037
+ console.error(`rigc: ${refusals.length} refusal(s) — nothing appended to ${ledgerPath}`);
1038
+ process.exit(1);
1039
+ }
1040
+ for (const rule of VOTE_RULES) console.log(` PASS ${rule}`);
1041
+
1042
+ line.seq = existing.length + 1;
1043
+ mkdirSync(dirname(ledgerPath), { recursive: true });
1044
+ appendFileSync(ledgerPath, ledgerLineText(line));
1045
+ console.log(
1046
+ ` .. ${line.choice === TIE ? 'tie' : `winner ${line.choice} = ${line.winner}`}, ` +
1047
+ `reason code ${line.reasonCode}${line.attempt > 1 ? `, attempt ${line.attempt}` : ''}`,
1048
+ );
1049
+ console.log(
1050
+ ` .. coverage ${line.coverage.length} candidate(s): ` +
1051
+ line.coverage.map((c) => `${c.label}=${c.digest.slice(0, 'sha256:'.length + 12)}…`).join(' '),
1052
+ );
1053
+ console.log(`rigc: appended line ${line.seq} to ${ledgerPath}`);
1054
+ }
1055
+
1056
+ function cmdVote(flags: Record<string, string>, candidates: string[]): void {
1057
+ if (flags.record !== undefined) cmdVoteRecord(flags);
1058
+ else if (candidates.length > 0) cmdVoteBallot(flags, candidates);
1059
+ else {
1060
+ throw new UsageError(
1061
+ 'vote takes either 2–4 --candidate <dir | skeleton.json> to write a ballot, or --record <result.json> to ' +
1062
+ 'record one that came back',
1063
+ );
1064
+ }
1065
+ }
1066
+
729
1067
  /**
730
1068
  * bench — run one rung of the benchmark ladder against a candidate rig.
731
1069
  *
@@ -1012,6 +1350,46 @@ function cmdExplain(flags: Record<string, string>): void {
1012
1350
  }
1013
1351
  }
1014
1352
  }
1353
+ // The two constraint groups: one unnamed timeline per constraint, so the
1354
+ // name printed is the constraint's and there is no timeline name to print
1355
+ // beside it. Every field a key carries is shown, because each one is
1356
+ // optional in the file and the ABSENT ones are what a reader has to see —
1357
+ // an omitted `softness` is 0, not "unchanged".
1358
+ for (const group of ['ik', 'transform'] as const) {
1359
+ for (const [name, keys] of Object.entries(anim[group] ?? {})) {
1360
+ console.log(` ${group}.${name} ${keys.length} key(s) <- one timeline per constraint`);
1361
+ for (const key of keys) {
1362
+ const fields = Object.entries(key)
1363
+ .filter(([k]) => k !== 'time' && k !== 'curve')
1364
+ .map(([k, v]) => `${k}=${String(v)}`)
1365
+ .join(' ');
1366
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1367
+ console.log(` t=${String(key.time).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
1368
+ }
1369
+ }
1370
+ }
1371
+ // Deform timelines are keyed on a skin/slot/attachment triple, and the run
1372
+ // is printed as its span rather than its numbers: `offset` plus a length is
1373
+ // what tells a reader whether the key lands where they meant, and a hundred
1374
+ // vertex offsets on one line tells them nothing.
1375
+ for (const [skinName, slotMap] of Object.entries(anim.attachments ?? {})) {
1376
+ for (const [slotName, attMap] of Object.entries(slotMap)) {
1377
+ for (const [attName, timelines] of Object.entries(attMap)) {
1378
+ for (const [timelineName, keys] of Object.entries(timelines)) {
1379
+ console.log(` ${skinName}/${slotName}/${attName}.${timelineName} ${keys.length} key(s)`);
1380
+ for (const key of keys) {
1381
+ const run = Array.isArray(key.vertices) ? (key.vertices as number[]) : null;
1382
+ const offset = typeof key.offset === 'number' ? key.offset : 0;
1383
+ const span = run
1384
+ ? `deform[${offset}..${offset + run.length}] ${run.length / 2} pair(s)`
1385
+ : 'back to the setup pose';
1386
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1387
+ console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
1388
+ }
1389
+ }
1390
+ }
1391
+ }
1392
+ }
1015
1393
  // The draw-order timeline names no target, so it hangs off the animation
1016
1394
  // rather than off a slot — and a timeline `explain` did not print would be a
1017
1395
  // timeline nobody could check without reading the emitted JSON.
@@ -1086,8 +1464,18 @@ const FLAG_MEANINGS: Record<string, string> = {
1086
1464
  as: 'the candidate animation to play, when it is named differently from the frame set',
1087
1465
  'all-frames': 'print every frame, not just the worst by MAE',
1088
1466
  json: 'also write the whole report to this path',
1467
+ frame: 'one pose frame — a picture of the pose to read the part placements out of',
1468
+ scale: `the scale window to search, as frame pixels per part pixel (default \`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`)`,
1469
+ rotation: 'the rotation window to search, in screen degrees (default `-180,180`, a full turn)',
1470
+ 'max-residual':
1471
+ `above this residual a placement is refused by name instead of reported flat (default ${DEFAULT_MAX_RESIDUAL}); ` +
1472
+ 'it is a reporting threshold, not a pass bar',
1089
1473
  animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
1090
1474
  max: 'longest side of a rendered frame, in pixels (default 256)',
1475
+ record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
1476
+ ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
1477
+ ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
1478
+ again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
1091
1479
  help: "show this command's flags and exit",
1092
1480
  };
1093
1481
 
@@ -1109,8 +1497,15 @@ const FLAG_VALUES: Record<string, string> = {
1109
1497
  framing: 'per-shot|shared',
1110
1498
  as: '<name>',
1111
1499
  json: '<out>',
1500
+ frame: '<path>',
1501
+ scale: '<min,max>',
1502
+ rotation: '<min,max>',
1503
+ 'max-residual': '<0..1>',
1112
1504
  animation: '<name>',
1113
1505
  max: '<px>',
1506
+ record: '<result.json>',
1507
+ ballot: '<ballot.html>',
1508
+ ledger: '<votes.jsonl>',
1114
1509
  };
1115
1510
 
1116
1511
  interface CommandDoc {
@@ -1123,12 +1518,14 @@ interface CommandDoc {
1123
1518
  * Per-command wording for a flag whose value or meaning genuinely differs here.
1124
1519
  *
1125
1520
  * ⚠️ The default above it — one meaning per flag name, everywhere — is the rule
1126
- * and this is the named exception to it, not a second table. Two flags earn it:
1521
+ * and this is the named exception to it, not a second table. Three flags earn it:
1127
1522
  * `--out` is a directory of artifacts to `build`, a directory of pictures to
1128
- * `render` and one file to `preview`; `--fps` is the rate a frame set was
1129
- * RECORDED at to `check`, which reads it off a sidecar, and the rate to SAMPLE
1130
- * at to `render`, which is choosing it. Writing either as one sentence covering
1131
- * every command would leave every command's own help less true than it is now.
1523
+ * `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
1524
+ * was RECORDED at to `check`, which reads it off a sidecar, and the rate to
1525
+ * SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
1526
+ * everywhere except `vote`, which is the one command that takes several and is
1527
+ * the reason there is a ballot at all. Writing any of them as one sentence
1528
+ * covering every command would leave every command's own help less true.
1132
1529
  */
1133
1530
  overrides?: Record<string, { value?: string; meaning?: string }>;
1134
1531
  }
@@ -1192,6 +1589,43 @@ const COMMANDS: CommandDoc[] = [
1192
1589
  },
1193
1590
  },
1194
1591
  },
1592
+ {
1593
+ name: 'pose',
1594
+ usage: [
1595
+ `rigc pose --images <dir> --frame <path> [--scale ${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}] [--rotation -180,180] [--out ${DEFAULT_POSE_OUT}]`,
1596
+ ],
1597
+ flags: ['images', 'frame', 'scale', 'rotation', 'max-residual', 'out'],
1598
+ overrides: {
1599
+ images: { value: '<dir>', meaning: 'the loose part PNGs to place; every `.png` in it is a part, in name order' },
1600
+ out: {
1601
+ value: '<file>',
1602
+ meaning: `the .json report to write (default \`${DEFAULT_POSE_OUT}\`); a directory means "the default name in here"`,
1603
+ },
1604
+ },
1605
+ },
1606
+ {
1607
+ name: 'vote',
1608
+ usage: [
1609
+ `rigc vote --candidate <dir | skeleton.json> --candidate <…> [--candidate …] [--animation <name>] [--out ${DEFAULT_BALLOT}]`,
1610
+ `rigc vote --record <result.json> [--ballot ${DEFAULT_BALLOT}] [--ledger ${DEFAULT_LEDGER}] [--again]`,
1611
+ ],
1612
+ flags: ['candidate', 'animation', 'out', 'record', 'ballot', 'ledger', 'again'],
1613
+ overrides: {
1614
+ candidate: {
1615
+ value: '<dir|skeleton.json>',
1616
+ meaning: `repeat it ${MIN_CANDIDATES}–${MAX_CANDIDATES} times — one compiled artifact per pane, labelled A, B, C, D in the order given`,
1617
+ },
1618
+ animation: {
1619
+ meaning:
1620
+ 'the one animation every pane plays (default: the first of candidate A). A candidate that does not have ' +
1621
+ 'it is refused — two panes playing two animations is not a comparison',
1622
+ },
1623
+ out: {
1624
+ value: '<file>',
1625
+ meaning: `the .html ballot to write (default \`${DEFAULT_BALLOT}\`); a directory means "the default name in here"`,
1626
+ },
1627
+ },
1628
+ },
1195
1629
  ];
1196
1630
 
1197
1631
  const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
@@ -1226,7 +1660,7 @@ const USAGE = [
1226
1660
  ' THE DEFAULT — 20 rules, and the question the output answers when',
1227
1661
  ' you import it into the Spine editor.',
1228
1662
  ' spine-html the above, plus this project\'s renderer and archetype policy:',
1229
- ' all 34 rules, opt-in. Those extra 14 fire on real, correct,',
1663
+ ' all 36 rules, opt-in. Those extra 14 fire on real, correct,',
1230
1664
  ' editor-produced Spine data, so they are somebody\'s policy rather',
1231
1665
  ' than anybody\'s validity.',
1232
1666
  '',
@@ -1246,6 +1680,24 @@ const USAGE = [
1246
1680
  'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
1247
1681
  'it in the official Spine Web Player, which is also the interop proof.',
1248
1682
  '',
1683
+ 'pose runs the other way round from everything above: it reads a picture you already',
1684
+ 'have — one key pose — and reports where each loose part PNG sits in it (x, y, rotation,',
1685
+ 'scale) so an agent can state those poses in a spec by construction:',
1686
+ ' rigc pose --images parts/ --frame poseA.png pose.json, one entry per part',
1687
+ 'It grades nothing and no pass bar attaches to its numbers. The residual is a trust',
1688
+ 'signal, and where two placements are equally good it reports BOTH rather than picking —',
1689
+ 'two identical limbs look exactly like that. A part that matches nowhere, a part the',
1690
+ 'canvas cannot contain and a part whose rotation is a free degree of freedom are each',
1691
+ 'named as such. See `rigc pose --help`.',
1692
+ '',
1693
+ 'vote is the same page with two to four builds in it and an answer coming back:',
1694
+ ' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
1695
+ ' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
1696
+ 'Reach for it where the instruments have run out — a choice with no reference behind',
1697
+ 'it, two fits that measure the same. The panes carry no paths, a tie is a recorded',
1698
+ 'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
1699
+ 'refused by name instead of appended.',
1700
+ '',
1249
1701
  'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
1250
1702
  ' "manifest": "..." (optional) } }, with every path',
1251
1703
  'resolved relative to the cuts.json file itself.',
@@ -1269,7 +1721,7 @@ try {
1269
1721
  throw new UsageError(`unknown command: ${command}`);
1270
1722
  }
1271
1723
 
1272
- const { flags, positional } = parseArgs(rest);
1724
+ const { flags, lists, positional } = parseArgs(rest, REPEATABLE_FLAGS[command]);
1273
1725
  if (flags.help !== undefined) {
1274
1726
  console.log(commandHelp(command));
1275
1727
  process.exit(0);
@@ -1282,11 +1734,19 @@ try {
1282
1734
  else if (command === 'bench') cmdBench(flags, positional);
1283
1735
  else if (command === 'render') cmdRender(flags);
1284
1736
  else if (command === 'preview') cmdPreview(flags);
1737
+ else if (command === 'pose') cmdPose(flags);
1738
+ else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
1285
1739
  } catch (err) {
1286
1740
  if (err instanceof UsageError) {
1287
1741
  console.error(`rigc: ${err.message}\n\n${USAGE}`);
1288
1742
  process.exit(2);
1289
1743
  }
1744
+ // A ballot refuses on its arguments, like a usage error, but its messages are
1745
+ // long enough that reprinting the whole usage under them buries the reason.
1746
+ if (err instanceof BallotError) {
1747
+ console.error(`rigc vote: ${err.message}`);
1748
+ process.exit(2);
1749
+ }
1290
1750
  if (err instanceof CompileError) {
1291
1751
  console.error(`rigc compile error: ${err.message}`);
1292
1752
  process.exit(1);
@@ -1295,5 +1755,12 @@ try {
1295
1755
  console.error(`rigc check error: ${err.message}`);
1296
1756
  process.exit(1);
1297
1757
  }
1758
+ // Like a usage error in kind — a missing directory, an unreadable frame — but
1759
+ // its messages name a path and a reason, and reprinting the whole usage under
1760
+ // them buries that.
1761
+ if (err instanceof PoseError) {
1762
+ console.error(`rigc pose: ${err.message}`);
1763
+ process.exit(2);
1764
+ }
1298
1765
  throw err;
1299
1766
  }