spine-rigc 1.1.0 → 1.2.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
@@ -323,6 +323,7 @@ the two files:
323
323
  .. pages=3 regions=3 bones=4 slots=3 animations=1 version=4.3.13 regionAttachments=3 meshAttachments=0 physicsConstraints=0 rig=buoy profile=spine
324
324
  rigc: wrote …/buoy/spine/skeleton.json
325
325
  rigc: wrote …/buoy/spine/skeleton.atlas
326
+ rigc: look at it: rigc preview --candidate …/buoy/spine
326
327
  ```
327
328
 
328
329
  `profile=spine` is the rulebook that judged it: *is this valid Spine 4.3 that any
@@ -532,14 +533,14 @@ commands take it and what its default is.
532
533
  | `validate <dir>` | re-gates artifacts already on disk |
533
534
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
534
535
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
535
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
536
- | `preview --candidate <dir>` | one self-contained `.html` that plays it |
536
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference |
537
+ | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
537
538
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
538
539
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
539
540
  | `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. A bone with two or more anchored descendants is **determined** rather than searched, and the residual that over-determination leaves is reported |
540
541
  | `diff <candidate.json> <reference.json>` | structural comparison of two skeletons, one ratio per measure and deliberately no combined score |
541
542
  | `bonedist --candidate … --reference … --bones …` | per-frame, per-bone world-transform distance against another skeleton — the ladder's stage 3, run on its own. `--bones <correspondence.json \| identity>` is required rather than defaulted: a candidate is entitled to its own bone names, so the pairing is stated |
542
- | `check --candidate <dir> --frames <dir>` | the candidate against reference pictures — the only instrument here that can see a *wrong animation* |
543
+ | `check --candidate <dir> --frames <dir> [--out <dir>]` | the candidate against reference pictures — the only instrument here that can see a *wrong animation*. `--out` also writes, for every frame the table lists, the picture its figures came from: reference, candidate, difference and overlay at native size |
543
544
  | `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
544
545
  | `skills install [--dir …] [--copy]` | links every agent skill the package ships into `.agents/skills` (or `--dir`) as relative symlinks, or copies them with `--copy` — where Codex, Gemini CLI and Antigravity look. A second run has nothing to do; an entry already there that is not this command's is refused by name and nothing is written |
545
546
 
package/cli.ts CHANGED
@@ -46,7 +46,7 @@ import {
46
46
  symlinkSync,
47
47
  writeFileSync,
48
48
  } from 'node:fs';
49
- import { basename, dirname, join, relative, resolve } from 'node:path';
49
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
50
50
  import {
51
51
  BallotError,
52
52
  buildBallot,
@@ -70,7 +70,15 @@ import {
70
70
  IDENTITY_CORRESPONDENCE,
71
71
  type BoneDistReport,
72
72
  } from './src/bonedist.ts';
73
- import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
73
+ import {
74
+ checkAgainstFrames,
75
+ checkLines,
76
+ CheckError,
77
+ CheckPlates,
78
+ type CheckOptions,
79
+ type CheckReport,
80
+ } from './src/check.ts';
81
+ import { writeCheckPictures } from './src/checkpics.ts';
74
82
  import { compile, CompileError, droppedStateReason, relativeImagesPath, type CompileOptions } from './src/compile.ts';
75
83
  import {
76
84
  skeletonDataFromText,
@@ -125,7 +133,7 @@ import {
125
133
  estimateChainFit,
126
134
  type ChainFitOptions,
127
135
  } from './src/chainfit.ts';
128
- import { buildPreview, PLAYER_LINE, type PreviewPage } from './src/preview.ts';
136
+ import { buildPreview, buildPreviewPanes, PLAYER_LINE, type PreviewGate, type PreviewInput, type PreviewPage } from './src/preview.ts';
129
137
  import {
130
138
  atlasPageNames,
131
139
  BACKGROUND,
@@ -141,9 +149,13 @@ import {
141
149
  SETUP_POSE_DIR,
142
150
  SHEET_FILE,
143
151
  SHEET_TILE,
152
+ SlotSubsetError,
153
+ slotSubsetOf,
144
154
  type Frame,
145
155
  type FramesSidecar,
146
156
  type FrameSet,
157
+ type Posable,
158
+ type SlotSubset,
147
159
  } from './src/render.ts';
148
160
  import {
149
161
  assertionCountForProfile,
@@ -249,8 +261,9 @@ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images',
249
261
  * The flags a command is allowed to spell more than once.
250
262
  *
251
263
  * `vote --candidate` is, because a ballot is *by definition* several
252
- * candidates, and `diff --as` is, because a skeleton has as many shots as it
253
- * has and one pairing per flag is the only spelling that keeps each pair a pair
264
+ * candidates; `preview --candidate` is, because a pane per candidate on one
265
+ * page is what an agent otherwise builds by hand (issue #837); and `diff --as`
266
+ * is, because a skeleton has as many shots as it has and one pairing per flag is the only spelling that keeps each pair a pair
254
267
  * (issue #720). Everywhere else a repeat is a mistake and is refused: `check
255
268
  * --candidate a --candidate b` used to take `b` silently, which is a report
256
269
  * about a rig the caller did not think they were asking about.
@@ -258,6 +271,7 @@ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images',
258
271
  const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
259
272
  vote: new Set(['candidate']),
260
273
  diff: new Set(['as']),
274
+ preview: new Set(['candidate']),
261
275
  };
262
276
 
263
277
  /**
@@ -1484,6 +1498,12 @@ function cmdBuild(flags: Record<string, string>): void {
1484
1498
  writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
1485
1499
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
1486
1500
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
1501
+ // The next command is part of the message (issue #837). A green build is the
1502
+ // moment somebody wants to see what came out, and the one page rigc writes
1503
+ // for that is `preview` of exactly this directory — so the line names it,
1504
+ // with the path already resolved. Printed only here: a red build wrote
1505
+ // nothing, so there is nothing to look at and no line.
1506
+ console.log(`rigc: look at it: rigc preview --candidate ${opts.outDir}`);
1487
1507
  }
1488
1508
 
1489
1509
  /**
@@ -1700,7 +1720,13 @@ function readCheckFlags(
1700
1720
  return out;
1701
1721
  }
1702
1722
 
1703
- function runCheck(candidate: string, atlasFlag: string | undefined, framesDir: string, flags: Record<string, string>): CheckReport {
1723
+ function runCheck(
1724
+ candidate: string,
1725
+ atlasFlag: string | undefined,
1726
+ framesDir: string,
1727
+ flags: Record<string, string>,
1728
+ plates?: CheckPlates,
1729
+ ): CheckReport {
1704
1730
  const { skeletonPath, atlasPath } = resolveArtifacts(candidate, atlasFlag);
1705
1731
  return checkAgainstFrames({
1706
1732
  skeletonText: readFileSync(skeletonPath, 'utf8'),
@@ -1709,16 +1735,59 @@ function runCheck(candidate: string, atlasFlag: string | undefined, framesDir: s
1709
1735
  framesDir,
1710
1736
  labels: { skeleton: skeletonPath, atlas: atlasPath },
1711
1737
  ...readCheckFlags(flags),
1738
+ ...(plates === undefined ? {} : { plates }),
1712
1739
  });
1713
1740
  }
1714
1741
 
1742
+ /**
1743
+ * `check --out <dir>`, refused before anything is compared when it cannot be
1744
+ * written — see `src/checkpics.ts` for what goes there.
1745
+ *
1746
+ * Two refusals and no others. A **file** at the path is not a directory of
1747
+ * pictures, and saying so beats the `ENOTDIR` a write would throw after the
1748
+ * whole comparison had run. And a directory that **is `--frames` or holds it**
1749
+ * is refused because `<out>/<set>/` is cleared and `<out>/frames.json` written,
1750
+ * as `render` does to its own: there, that is the reference set being deleted by
1751
+ * the command that reads it — `check --frames render --out render` would clear
1752
+ * `render/heavy` and put a picture sidecar where the frames' own was. A fresh
1753
+ * path, and an existing directory anywhere else, are written to.
1754
+ */
1755
+ function readCheckOut(outFlag: string, framesFlag: string): string {
1756
+ const out = resolve(outFlag);
1757
+ if (existsSync(out) && !statSync(out).isDirectory()) {
1758
+ throw new UsageError(`check: --out ${out} is a file; it names a directory`);
1759
+ }
1760
+ const frames = resolve(framesFlag);
1761
+ const within = relative(out, frames);
1762
+ if (within === '' || (!within.startsWith('..') && !isAbsolute(within))) {
1763
+ throw new UsageError(
1764
+ `check: --out ${out} ${within === '' ? 'is' : 'holds'} --frames ${frames}; the pictures would be written ` +
1765
+ 'over the frames they are pictures of (each set directory under --out is cleared first) — name a directory ' +
1766
+ 'outside it',
1767
+ );
1768
+ }
1769
+ return out;
1770
+ }
1771
+
1715
1772
  function cmdCheck(flags: Record<string, string>): void {
1716
1773
  if (flags.candidate === undefined) throw new UsageError('check needs --candidate <dir | skeleton.json>');
1717
1774
  if (flags.frames === undefined) throw new UsageError('check needs --frames <dir> — a rendered reference frame set');
1718
- const report = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
1775
+ const out = flags.out === undefined ? null : readCheckOut(flags.out, flags.frames);
1776
+ const allFrames = flags['all-frames'] !== undefined;
1777
+ // Only asked for when there is somewhere to put the pictures: without --out
1778
+ // nothing is kept, and the run is the run it was before --out existed.
1779
+ const plates = out === null ? undefined : new CheckPlates({ allFrames });
1780
+ const report = runCheck(flags.candidate, flags.atlas, flags.frames, flags, plates);
1719
1781
  console.log('rigc check');
1720
- for (const line of checkLines(report, { allFrames: flags['all-frames'] !== undefined })) console.log(line);
1782
+ for (const line of checkLines(report, { allFrames })) console.log(line);
1721
1783
  if (flags.json !== undefined) writeJson(flags.json, report);
1784
+ if (out !== null && plates !== undefined) {
1785
+ for (const set of writeCheckPictures(out, report, plates, { allFrames })) {
1786
+ const which = set.frames.length === 0 ? 'nothing compared' : set.every ? 'every compared frame' : 'the frames worth reading';
1787
+ console.log(` .. ${set.dir.padEnd(16)} ${set.frames.length} picture(s), ${which} -> ${set.path}`);
1788
+ }
1789
+ console.log(`rigc: wrote ${join(out, FRAMES_SIDECAR)}`);
1790
+ }
1722
1791
  }
1723
1792
 
1724
1793
  function writeJson(target: string, body: unknown): void {
@@ -1801,6 +1870,35 @@ function readSkinFlag(flags: Record<string, string>, declared: string[]): string
1801
1870
  return name;
1802
1871
  }
1803
1872
 
1873
+ /**
1874
+ * `--slot` / `--hide`, resolved against the skeleton under the skin this run
1875
+ * poses it in — or refused as a usage error, nothing written (issue #835).
1876
+ *
1877
+ * The rule itself is `slotSubsetOf`'s in `src/render.ts`, which `piecesOf`
1878
+ * applies too: it is read here only so a miss exits 2 with the usage beside
1879
+ * it before a directory is created, rather than surfacing from the sampler.
1880
+ */
1881
+ function readSlotSubsetFlags(
1882
+ flags: Record<string, string>,
1883
+ data: Posable['data'],
1884
+ skin: string | undefined,
1885
+ ): SlotSubset | undefined {
1886
+ const list = (raw: string | undefined): string[] | undefined =>
1887
+ raw === undefined ? undefined : raw.split(',').map((name) => name.trim()).filter((name) => name !== '');
1888
+ try {
1889
+ return slotSubsetOf(data, { slots: list(flags.slot), hidden: list(flags.hide) }, skin);
1890
+ } catch (err) {
1891
+ if (err instanceof SlotSubsetError) throw new UsageError(err.message);
1892
+ throw err;
1893
+ }
1894
+ }
1895
+
1896
+ /** A resolved subset as the one field it is spelled as, in `PoseOptions` and in `frames.json` alike. */
1897
+ function subsetFields(subset: SlotSubset | undefined): { slots?: string[]; hidden?: string[] } {
1898
+ if (subset === undefined) return {};
1899
+ return subset.mode === 'slots' ? { slots: subset.names } : { hidden: subset.names };
1900
+ }
1901
+
1804
1902
  function readPositiveNumber(flags: Record<string, string>, key: string, fallback: number, least: number): number {
1805
1903
  const raw = flags[key];
1806
1904
  if (raw === undefined) return fallback;
@@ -1869,12 +1967,19 @@ function cmdRender(flags: Record<string, string>): void {
1869
1967
  const { data, pages } = loadPosable(skeletonPath, atlasPath, atlasDir);
1870
1968
  const only = readAnimationFlag(flags, data.animations.map((a) => a.name));
1871
1969
  const skin = readSkinFlag(flags, data.skins.map((s) => s.name));
1970
+ const subset = readSlotSubsetFlags(flags, data, skin);
1872
1971
  // One object, so the framing and the frames cannot be posed under two
1873
1972
  // different skins — which would frame one shot with another shot's box.
1874
1973
  // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
1875
1974
  // `src/render.ts` has one of its own. The inferred shape is the render one.
1876
- const pose = skin === undefined ? undefined : { skin };
1975
+ // The subset rides on the same object and `framingViewport` takes it off, so
1976
+ // the frames draw the subset and the box is still the whole rig's.
1977
+ const pose =
1978
+ skin === undefined && subset === undefined
1979
+ ? undefined
1980
+ : { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset) };
1877
1981
  if (skin !== undefined) console.log(` .. skin ${skin}`);
1982
+ if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
1878
1983
 
1879
1984
  const viewport = framingViewport(data, maxSide, pose);
1880
1985
  if (!viewport) {
@@ -1936,6 +2041,9 @@ function cmdRender(flags: Record<string, string>): void {
1936
2041
  // which is both what this run did and what every frame set written before
1937
2042
  // #571 did. See `FramesSidecar.skin`.
1938
2043
  ...(skin === undefined ? {} : { skin }),
2044
+ // Written only when a subset was asked for, for the same reason: a render of
2045
+ // every slot says nothing and stays the bytes it always was (issue #835).
2046
+ ...subsetFields(subset),
1939
2047
  background: BACKGROUND,
1940
2048
  viewport: {
1941
2049
  x: viewport.minX,
@@ -1969,6 +2077,29 @@ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
1969
2077
  return Object.keys(animations);
1970
2078
  }
1971
2079
 
2080
+ /**
2081
+ * The gate's reading of one candidate, taken the way `rigc validate <dir>`
2082
+ * takes it (issue #837).
2083
+ *
2084
+ * ⭐ The same call `cmdValidate` makes on a bare directory: the two texts, the
2085
+ * atlas's own directory, the default profile, and nothing a directory cannot
2086
+ * supply — no rig spec, no declared durations, no second compile. So `A09` and
2087
+ * `A18` report SKIP here exactly as they do there, and the line is the line
2088
+ * that command prints for these files, not the one `build` printed for the
2089
+ * compile that wrote them. Measured on every run, because the page must not
2090
+ * carry a figure this run did not measure.
2091
+ */
2092
+ function previewGate(skeletonText: string, atlasText: string, atlasDir: string): PreviewGate {
2093
+ const lines = reportLines(validate({ skeletonText, atlasText, atlasDir, profile: CLI_DEFAULT_PROFILE }));
2094
+ const refusal = lines.find((line) => line.startsWith(' FAIL '));
2095
+ return {
2096
+ // `reportLines` ends on the summary by construction; the gutter is the
2097
+ // report's layout, not part of what the gate said.
2098
+ summary: lines[lines.length - 1].replace(/^ {2}\.\. {4}/, ''),
2099
+ refusal: refusal === undefined ? null : refusal.trimStart(),
2100
+ };
2101
+ }
2102
+
1972
2103
  /**
1973
2104
  * preview — the artifact playing in Esoteric's own web player, as one file.
1974
2105
  *
@@ -1977,52 +2108,130 @@ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
1977
2108
  * can draw rather than for the ones our own decoder reads — which is the right
1978
2109
  * direction for the command whose whole job is "just show me".
1979
2110
  */
1980
- function cmdPreview(flags: Record<string, string>): void {
1981
- const { skeletonPath, atlasPath, atlasDir } = resolveViewable(flags);
1982
- const skeletonText = readFileSync(skeletonPath, 'utf8');
1983
- const atlasText = readFileSync(atlasPath, 'utf8');
1984
- const animations = skeletonAnimationNames(skeletonText, skeletonPath);
1985
- const chosen = readAnimationFlag(flags, animations);
2111
+ function cmdPreview(flags: Record<string, string>, candidates: string[]): void {
2112
+ // Refused rather than ignored: a preview asked to hide `head` that plays the
2113
+ // whole rig is a picture that answers a question it was not asked (issue #835).
2114
+ for (const flag of ['slot', 'hide'] as const) {
2115
+ if (flags[flag] !== undefined) {
2116
+ throw new UsageError(
2117
+ `preview takes no --${flag}: the Spine Web Player draws what the skeleton draws. A subset of the slots is ` +
2118
+ `\`rigc render --${flag} ${flags[flag]}\`, on the whole rig's grid`,
2119
+ );
2120
+ }
2121
+ }
2122
+ const several = candidates.length > 1;
2123
+ // `--atlas` names ONE atlas, and with several skeletons there is no
2124
+ // unambiguous thing it could mean — the refusal `vote` makes, for the reason
2125
+ // it makes it.
2126
+ if (several && flags.atlas !== undefined) {
2127
+ throw new UsageError(
2128
+ `--atlas names one atlas and ${candidates.length} --candidate were given; each candidate's atlas has to sit ` +
2129
+ 'beside its skeleton, which is what `build --out` leaves behind',
2130
+ );
2131
+ }
2132
+ const found = several
2133
+ ? candidates.map((target) => {
2134
+ const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
2135
+ for (const path of [skeletonPath, atlasPath]) {
2136
+ if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
2137
+ }
2138
+ return { target, skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
2139
+ })
2140
+ : [{ target: flags.candidate, ...resolveViewable(flags) }];
2141
+ // ⚠️ By the FILE each one resolves to, not by the text typed: `build/` and
2142
+ // `build/skeleton.json` are two spellings of one candidate, and so are
2143
+ // `/tmp/x` and `/private/tmp/x` on a machine where one is a link to the other
2144
+ // — `resolve` alone left that pair unrefused, measured on macOS. A page
2145
+ // showing one skeleton twice is two panes that look like a comparison of
2146
+ // nothing. (`vote` accepts a repeat today, exit 0; that is its own card.)
2147
+ const identities = found.map((f) => realpathSync(f.skeletonPath));
2148
+ for (let i = 1; i < found.length; i++) {
2149
+ const first = identities.indexOf(identities[i]);
2150
+ if (first < i) {
2151
+ throw new UsageError(
2152
+ `--candidate ${JSON.stringify(found[i].target)} is ${identities[i]}, which --candidate ` +
2153
+ `${JSON.stringify(found[first].target)} already names (candidates ${first + 1} and ${i + 1}); a pane per ` +
2154
+ 'candidate would show the same skeleton twice',
2155
+ );
2156
+ }
2157
+ }
2158
+
2159
+ // Every candidate's texts and animation are read before a line is printed,
2160
+ // so a refusal about any of them comes before the report, as it always has.
2161
+ const loaded = found.map((f, i) => {
2162
+ const skeletonText = readFileSync(f.skeletonPath, 'utf8');
2163
+ const atlasText = readFileSync(f.atlasPath, 'utf8');
2164
+ const animations = skeletonAnimationNames(skeletonText, f.skeletonPath);
2165
+ let chosen: string | undefined;
2166
+ try {
2167
+ chosen = readAnimationFlag(flags, animations);
2168
+ } catch (err) {
2169
+ // With several candidates the refusal has to say WHICH one lacks it.
2170
+ if (several && err instanceof UsageError) {
2171
+ throw new UsageError(`candidate ${i + 1} (${f.skeletonPath}): ${err.message}`);
2172
+ }
2173
+ throw err;
2174
+ }
2175
+ return { ...f, skeletonText, atlasText, animations, chosen };
2176
+ });
1986
2177
 
1987
2178
  // A directory for --out is taken as "put the default name in here", because
1988
2179
  // `--out render/` is what the sibling command means by the same flag and a
1989
2180
  // preview written OVER a directory is not a recoverable mistake.
1990
2181
  const target = resolve(flags.out ?? 'preview.html');
1991
2182
  const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
2183
+ const version = readVersion();
1992
2184
 
1993
2185
  console.log('rigc preview');
1994
- console.log(` .. skeleton ${skeletonPath}`);
1995
- console.log(` .. atlas ${atlasPath}`);
1996
-
1997
- const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
1998
- const path = join(atlasDir, name);
1999
- if (!existsSync(path)) {
2000
- throw new UsageError(
2001
- `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
2002
- 'a page a preview cannot embed is a page the player could not have loaded either',
2186
+ const inputs: PreviewInput[] = loaded.map((candidate, i) => {
2187
+ const { skeletonPath, atlasPath, atlasDir, skeletonText, atlasText, animations, chosen } = candidate;
2188
+ if (several) console.log(` .. pane ${i + 1} of ${loaded.length}`);
2189
+ console.log(` .. skeleton ${skeletonPath}`);
2190
+ console.log(` .. atlas ${atlasPath}`);
2191
+ const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
2192
+ const path = join(atlasDir, name);
2193
+ if (!existsSync(path)) {
2194
+ throw new UsageError(
2195
+ `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
2196
+ 'a page a preview cannot embed is a page the player could not have loaded either',
2197
+ );
2198
+ }
2199
+ return { name, bytes: readFileSync(path) };
2200
+ });
2201
+ for (const page of pages) {
2202
+ console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
2203
+ }
2204
+ if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
2205
+ const gate = previewGate(skeletonText, atlasText, atlasDir);
2206
+ console.log(` .. gate ${gate.summary}`);
2207
+ if (gate.refusal !== null) {
2208
+ console.log(
2209
+ ` .. gate refused — ${gate.refusal}. Previewed anyway: looking at a red build is what preview is ` +
2210
+ 'for, and the page header says the same',
2003
2211
  );
2004
2212
  }
2005
- return { name, bytes: readFileSync(path) };
2213
+ return {
2214
+ skeletonText,
2215
+ atlasText,
2216
+ pages,
2217
+ animation: chosen ?? animations[0] ?? null,
2218
+ animations,
2219
+ label: skeletonPath,
2220
+ version,
2221
+ gate,
2222
+ };
2006
2223
  });
2007
- for (const page of pages) {
2008
- console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
2009
- }
2010
- if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
2011
2224
 
2012
- const html = buildPreview({
2013
- skeletonText,
2014
- atlasText,
2015
- pages,
2016
- animation: chosen ?? animations[0] ?? null,
2017
- animations,
2018
- label: skeletonPath,
2019
- version: readVersion(),
2020
- });
2225
+ const html = several ? buildPreviewPanes(inputs, version) : buildPreview(inputs[0]);
2021
2226
  mkdirSync(dirname(out), { recursive: true });
2022
2227
  writeFileSync(out, html);
2228
+ const pageCount = inputs.reduce((n, input) => n + input.pages.length, 0);
2023
2229
  console.log(
2024
- ` .. embedded ${pages.length} page(s) + the skeleton and atlas as data URIs; ` +
2025
- `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
2230
+ several
2231
+ ? ` .. embedded ${pageCount} page(s) + ${inputs.length} skeletons and atlases as data URIs, one pane each; ` +
2232
+ `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`
2233
+ : ` .. embedded ${pageCount} page(s) + the skeleton and atlas as data URIs; ` +
2234
+ `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
2026
2235
  );
2027
2236
  console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
2028
2237
  }
@@ -2195,8 +2404,8 @@ function cmdChainFit(flags: Record<string, string>): void {
2195
2404
  // choosing between results — vote
2196
2405
  // ---------------------------------------------------------------------------
2197
2406
  //
2198
- // ⭐ `preview` shows one candidate; this shows two to four of them side by side
2199
- // and takes an answer back. The rest of this toolchain is instruments, and it
2407
+ // ⭐ `preview` shows candidates and asks nothing; this shows two to four of them
2408
+ // side by side, hides where each came from, and takes an answer back. The rest of this toolchain is instruments, and it
2200
2409
  // should be — the vote opens only where the instruments have already run out.
2201
2410
  // See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
2202
2411
  // why the labels are A and B, and why the record is hashes.
@@ -3594,6 +3803,14 @@ const FLAG_MEANINGS: Record<string, string> = {
3594
3803
  'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
3595
3804
  'skeleton does not declare is refused with the ones it does. `render` records the skin in frames.json and ' +
3596
3805
  '`check` reads it back, so a skin-A candidate is not scored against skin-B frames in silence',
3806
+ slot:
3807
+ 'draw only these slots, comma-separated, in the skeleton\'s draw order, on the SAME grid as the whole rig: the ' +
3808
+ 'viewport is still fitted to every slot, so this frame overlays the full one pixel for pixel. A name the ' +
3809
+ 'skeleton does not declare is refused with every one it does; a slot whose art lives only under another skin ' +
3810
+ 'is refused naming that skin. frames.json records the subset, and `check` refuses such a set as a reference',
3811
+ hide:
3812
+ 'draw every slot but these, comma-separated — `--slot` the other way round, on the same grid, recorded and ' +
3813
+ 'refused the same way. Not with `--slot`: the two are one statement',
3597
3814
  max: 'longest side of a rendered frame, in pixels (default 256)',
3598
3815
  record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
3599
3816
  ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
@@ -3662,6 +3879,8 @@ const FLAG_VALUES: Record<string, string> = {
3662
3879
  'inward-lever': '<px>',
3663
3880
  animation: '<name>',
3664
3881
  skin: '<name>',
3882
+ slot: '<name[,name…]>',
3883
+ hide: '<name[,name…]>',
3665
3884
  max: '<px>',
3666
3885
  record: '<result.json>',
3667
3886
  ballot: '<ballot.html>',
@@ -3812,7 +4031,7 @@ const COMMANDS: CommandDoc[] = [
3812
4031
  {
3813
4032
  name: 'check',
3814
4033
  usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
3815
- flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'all-frames', 'json'],
4034
+ flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'all-frames', 'json', 'out'],
3816
4035
  overrides: {
3817
4036
  skin: {
3818
4037
  meaning:
@@ -3821,6 +4040,16 @@ const COMMANDS: CommandDoc[] = [
3821
4040
  'contested art. The frames are checked back: a set whose frames.json records a different skin is ' +
3822
4041
  'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
3823
4042
  },
4043
+ out: {
4044
+ value: '<dir>',
4045
+ meaning:
4046
+ 'also write the PICTURE each listed frame\'s figures came from, as <dir>/<set>/f####.png: reference, ' +
4047
+ 'candidate, difference and overlay side by side at the comparison grid\'s native size, with the table\'s ' +
4048
+ 'figures burned in and one row per slot under them, beside a frames.json that says what they are pictures ' +
4049
+ 'of. The frames are the ones the table lists, so --all-frames writes every compared one. Each <dir>/<set>/ ' +
4050
+ 'is cleared first; a file at <dir>, or a directory that is or holds --frames, is refused. See ' +
4051
+ 'docs/AUTHORING.md §9.2.1',
4052
+ },
3824
4053
  },
3825
4054
  notes: [
3826
4055
  'every figure here is a reporting threshold, not a pass bar. Nothing in this report',
@@ -3863,8 +4092,9 @@ const COMMANDS: CommandDoc[] = [
3863
4092
  name: 'render',
3864
4093
  usage: [
3865
4094
  'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--out render/]',
4095
+ 'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
3866
4096
  ],
3867
- flags: ['candidate', 'atlas', 'animation', 'skin', 'fps', 'max', 'out'],
4097
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'out'],
3868
4098
  overrides: {
3869
4099
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
3870
4100
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -3872,9 +4102,22 @@ const COMMANDS: CommandDoc[] = [
3872
4102
  },
3873
4103
  {
3874
4104
  name: 'preview',
3875
- usage: ['rigc preview --candidate <dir | skeleton.json> [--animation <name>] [--out preview.html]'],
4105
+ usage: ['rigc preview --candidate <dir | skeleton.json> [--candidate <another> …] [--animation <name>] [--out preview.html]'],
3876
4106
  flags: ['candidate', 'atlas', 'animation', 'out'],
3877
4107
  overrides: {
4108
+ candidate: {
4109
+ value: '<dir|skeleton.json>',
4110
+ meaning:
4111
+ 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path. Repeat it ' +
4112
+ 'for one page with a pane per candidate, in the order given; the same skeleton twice is refused. Each ' +
4113
+ 'header carries the line `rigc validate <dir>` prints for that candidate, measured when the page is written',
4114
+ },
4115
+ atlas: { meaning: "the candidate's atlas, when it is not beside the skeleton — one candidate only" },
4116
+ animation: {
4117
+ meaning:
4118
+ "the animation to start on (default: each candidate's own first). With several candidates every one of " +
4119
+ 'them must have it, or the run is refused naming the one that does not',
4120
+ },
3878
4121
  out: {
3879
4122
  value: '<file>',
3880
4123
  meaning: 'the .html file to write (default `preview.html`); a directory means "the default name in here"',
@@ -4128,7 +4371,7 @@ try {
4128
4371
  else if (command === 'bench') cmdBench(flags, positional);
4129
4372
  else if (command === 'bonedist') cmdBoneDist(flags);
4130
4373
  else if (command === 'render') cmdRender(flags);
4131
- else if (command === 'preview') cmdPreview(flags);
4374
+ else if (command === 'preview') cmdPreview(flags, lists.candidate ?? []);
4132
4375
  else if (command === 'pose') cmdPose(flags);
4133
4376
  else if (command === 'chainfit') cmdChainFit(flags);
4134
4377
  else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);