spine-rigc 1.0.2 → 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/cli.ts CHANGED
@@ -31,8 +31,22 @@
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 { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
35
- import { basename, dirname, join, resolve } from 'node:path';
34
+ import {
35
+ appendFileSync,
36
+ cpSync,
37
+ existsSync,
38
+ lstatSync,
39
+ mkdirSync,
40
+ readdirSync,
41
+ readFileSync,
42
+ readlinkSync,
43
+ realpathSync,
44
+ rmSync,
45
+ statSync,
46
+ symlinkSync,
47
+ writeFileSync,
48
+ } from 'node:fs';
49
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
36
50
  import {
37
51
  BallotError,
38
52
  buildBallot,
@@ -56,7 +70,15 @@ import {
56
70
  IDENTITY_CORRESPONDENCE,
57
71
  type BoneDistReport,
58
72
  } from './src/bonedist.ts';
59
- 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';
60
82
  import { compile, CompileError, droppedStateReason, relativeImagesPath, type CompileOptions } from './src/compile.ts';
61
83
  import {
62
84
  skeletonDataFromText,
@@ -111,7 +133,7 @@ import {
111
133
  estimateChainFit,
112
134
  type ChainFitOptions,
113
135
  } from './src/chainfit.ts';
114
- 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';
115
137
  import {
116
138
  atlasPageNames,
117
139
  BACKGROUND,
@@ -127,9 +149,13 @@ import {
127
149
  SETUP_POSE_DIR,
128
150
  SHEET_FILE,
129
151
  SHEET_TILE,
152
+ SlotSubsetError,
153
+ slotSubsetOf,
130
154
  type Frame,
131
155
  type FramesSidecar,
132
156
  type FrameSet,
157
+ type Posable,
158
+ type SlotSubset,
133
159
  } from './src/render.ts';
134
160
  import {
135
161
  assertionCountForProfile,
@@ -229,14 +255,15 @@ function repositoryUrl(): string {
229
255
  * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
230
256
  * halves together by reading `--help` rather than by naming a flag.
231
257
  */
232
- const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack']);
258
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy']);
233
259
 
234
260
  /**
235
261
  * The flags a command is allowed to spell more than once.
236
262
  *
237
263
  * `vote --candidate` is, because a ballot is *by definition* several
238
- * candidates, and `diff --as` is, because a skeleton has as many shots as it
239
- * 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
240
267
  * (issue #720). Everywhere else a repeat is a mistake and is refused: `check
241
268
  * --candidate a --candidate b` used to take `b` silently, which is a report
242
269
  * about a rig the caller did not think they were asking about.
@@ -244,6 +271,7 @@ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images',
244
271
  const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
245
272
  vote: new Set(['candidate']),
246
273
  diff: new Set(['as']),
274
+ preview: new Set(['candidate']),
247
275
  };
248
276
 
249
277
  /**
@@ -1470,6 +1498,12 @@ function cmdBuild(flags: Record<string, string>): void {
1470
1498
  writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
1471
1499
  console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
1472
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}`);
1473
1507
  }
1474
1508
 
1475
1509
  /**
@@ -1686,7 +1720,13 @@ function readCheckFlags(
1686
1720
  return out;
1687
1721
  }
1688
1722
 
1689
- 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 {
1690
1730
  const { skeletonPath, atlasPath } = resolveArtifacts(candidate, atlasFlag);
1691
1731
  return checkAgainstFrames({
1692
1732
  skeletonText: readFileSync(skeletonPath, 'utf8'),
@@ -1695,16 +1735,59 @@ function runCheck(candidate: string, atlasFlag: string | undefined, framesDir: s
1695
1735
  framesDir,
1696
1736
  labels: { skeleton: skeletonPath, atlas: atlasPath },
1697
1737
  ...readCheckFlags(flags),
1738
+ ...(plates === undefined ? {} : { plates }),
1698
1739
  });
1699
1740
  }
1700
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
+
1701
1772
  function cmdCheck(flags: Record<string, string>): void {
1702
1773
  if (flags.candidate === undefined) throw new UsageError('check needs --candidate <dir | skeleton.json>');
1703
1774
  if (flags.frames === undefined) throw new UsageError('check needs --frames <dir> — a rendered reference frame set');
1704
- 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);
1705
1781
  console.log('rigc check');
1706
- 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);
1707
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
+ }
1708
1791
  }
1709
1792
 
1710
1793
  function writeJson(target: string, body: unknown): void {
@@ -1787,6 +1870,35 @@ function readSkinFlag(flags: Record<string, string>, declared: string[]): string
1787
1870
  return name;
1788
1871
  }
1789
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
+
1790
1902
  function readPositiveNumber(flags: Record<string, string>, key: string, fallback: number, least: number): number {
1791
1903
  const raw = flags[key];
1792
1904
  if (raw === undefined) return fallback;
@@ -1855,12 +1967,19 @@ function cmdRender(flags: Record<string, string>): void {
1855
1967
  const { data, pages } = loadPosable(skeletonPath, atlasPath, atlasDir);
1856
1968
  const only = readAnimationFlag(flags, data.animations.map((a) => a.name));
1857
1969
  const skin = readSkinFlag(flags, data.skins.map((s) => s.name));
1970
+ const subset = readSlotSubsetFlags(flags, data, skin);
1858
1971
  // One object, so the framing and the frames cannot be posed under two
1859
1972
  // different skins — which would frame one shot with another shot's box.
1860
1973
  // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
1861
1974
  // `src/render.ts` has one of its own. The inferred shape is the render one.
1862
- 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) };
1863
1981
  if (skin !== undefined) console.log(` .. skin ${skin}`);
1982
+ if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
1864
1983
 
1865
1984
  const viewport = framingViewport(data, maxSide, pose);
1866
1985
  if (!viewport) {
@@ -1922,6 +2041,9 @@ function cmdRender(flags: Record<string, string>): void {
1922
2041
  // which is both what this run did and what every frame set written before
1923
2042
  // #571 did. See `FramesSidecar.skin`.
1924
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),
1925
2047
  background: BACKGROUND,
1926
2048
  viewport: {
1927
2049
  x: viewport.minX,
@@ -1955,6 +2077,29 @@ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
1955
2077
  return Object.keys(animations);
1956
2078
  }
1957
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
+
1958
2103
  /**
1959
2104
  * preview — the artifact playing in Esoteric's own web player, as one file.
1960
2105
  *
@@ -1963,52 +2108,130 @@ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
1963
2108
  * can draw rather than for the ones our own decoder reads — which is the right
1964
2109
  * direction for the command whose whole job is "just show me".
1965
2110
  */
1966
- function cmdPreview(flags: Record<string, string>): void {
1967
- const { skeletonPath, atlasPath, atlasDir } = resolveViewable(flags);
1968
- const skeletonText = readFileSync(skeletonPath, 'utf8');
1969
- const atlasText = readFileSync(atlasPath, 'utf8');
1970
- const animations = skeletonAnimationNames(skeletonText, skeletonPath);
1971
- 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
+ });
1972
2177
 
1973
2178
  // A directory for --out is taken as "put the default name in here", because
1974
2179
  // `--out render/` is what the sibling command means by the same flag and a
1975
2180
  // preview written OVER a directory is not a recoverable mistake.
1976
2181
  const target = resolve(flags.out ?? 'preview.html');
1977
2182
  const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
2183
+ const version = readVersion();
1978
2184
 
1979
2185
  console.log('rigc preview');
1980
- console.log(` .. skeleton ${skeletonPath}`);
1981
- console.log(` .. atlas ${atlasPath}`);
1982
-
1983
- const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
1984
- const path = join(atlasDir, name);
1985
- if (!existsSync(path)) {
1986
- throw new UsageError(
1987
- `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
1988
- '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',
1989
2211
  );
1990
2212
  }
1991
- 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
+ };
1992
2223
  });
1993
- for (const page of pages) {
1994
- console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
1995
- }
1996
- if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
1997
2224
 
1998
- const html = buildPreview({
1999
- skeletonText,
2000
- atlasText,
2001
- pages,
2002
- animation: chosen ?? animations[0] ?? null,
2003
- animations,
2004
- label: skeletonPath,
2005
- version: readVersion(),
2006
- });
2225
+ const html = several ? buildPreviewPanes(inputs, version) : buildPreview(inputs[0]);
2007
2226
  mkdirSync(dirname(out), { recursive: true });
2008
2227
  writeFileSync(out, html);
2228
+ const pageCount = inputs.reduce((n, input) => n + input.pages.length, 0);
2009
2229
  console.log(
2010
- ` .. embedded ${pages.length} page(s) + the skeleton and atlas as data URIs; ` +
2011
- `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`,
2012
2235
  );
2013
2236
  console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
2014
2237
  }
@@ -2181,8 +2404,8 @@ function cmdChainFit(flags: Record<string, string>): void {
2181
2404
  // choosing between results — vote
2182
2405
  // ---------------------------------------------------------------------------
2183
2406
  //
2184
- // ⭐ `preview` shows one candidate; this shows two to four of them side by side
2185
- // 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
2186
2409
  // should be — the vote opens only where the instruments have already run out.
2187
2410
  // See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
2188
2411
  // why the labels are A and B, and why the record is hashes.
@@ -3270,6 +3493,223 @@ function cmdIngest(flags: Record<string, string>, positional: string[]): void {
3270
3493
  }
3271
3494
  }
3272
3495
 
3496
+ // ---------------------------------------------------------------------------
3497
+ // skills install — put the shipped skills where an agent host looks (issue #831)
3498
+ // ---------------------------------------------------------------------------
3499
+ //
3500
+ // After `bun add -d spine-rigc` the skills sit at `node_modules/spine-rigc/skills/`,
3501
+ // which no host reads. Codex, Gemini CLI and Antigravity all read
3502
+ // `<workspace>/.agents/skills/<name>/`, so this links every `skills/<name>/` the
3503
+ // package ships into one directory — `.agents/skills` under the working
3504
+ // directory unless `--dir` says otherwise.
3505
+ //
3506
+ // ⭐ The skills are found from THIS FILE's location, never from the working
3507
+ // directory: the command installs the package it is, and a cwd that happens to
3508
+ // hold some other `skills/` is not a source. That is also why it lives here and
3509
+ // not in `src/`: its one input is where the CLI was installed, nothing else
3510
+ // calls it, and `src/` is about rigs.
3511
+ //
3512
+ // A RELATIVE symlink by default, so the directory survives the project being
3513
+ // moved or cloned elsewhere and an upgrade of the package is seen with no second
3514
+ // run. `--copy` writes the folder instead, for a host that does not follow a
3515
+ // linked skill folder.
3516
+ //
3517
+ // 🔒 **An entry that is already there and is not what this command would write is
3518
+ // refused by name, and then nothing at all is written.** The check runs over
3519
+ // every skill before the first write, so a refusal never leaves half an install
3520
+ // behind. The one entry that is NOT refused is the one this command would have
3521
+ // made — a link that already resolves to the same skill folder, however it is
3522
+ // spelled, or with `--copy` a folder whose files are byte for byte the package's
3523
+ // — and a run over only those says it had nothing to do. No lifecycle script
3524
+ // does this on install: a postinstall writing into a consumer's project root is
3525
+ // refused as design, and Bun does not run a dependency's lifecycle scripts
3526
+ // outside `trustedDependencies`, so half the installs would silently skip it.
3527
+ // ---------------------------------------------------------------------------
3528
+
3529
+ /** The default `--dir`, resolved against the caller's working directory. */
3530
+ const DEFAULT_SKILLS_DIR = '.agents/skills';
3531
+
3532
+ /** What `rigc skills` offers. One today; the list is what the refusal of any other word prints. */
3533
+ const SKILLS_SUBCOMMANDS = ['install'];
3534
+
3535
+ /** An install refused before its first write — nothing to install, or an entry in the way. Exit 1. */
3536
+ class SkillsInstallError extends Error {}
3537
+
3538
+ type SkillsInstallAction = 'linked' | 'copied' | 'already linked' | 'already copied';
3539
+
3540
+ interface SkillsInstallEntry {
3541
+ /** `<dir>/<name>`. */
3542
+ target: string;
3543
+ /** `<package>/skills/<name>`. */
3544
+ source: string;
3545
+ action: SkillsInstallAction;
3546
+ /** The link text, relative to the directory it sits in, when the entry is a link. */
3547
+ link: string;
3548
+ }
3549
+
3550
+ /** Every `<name>/` under `source` that holds a `SKILL.md`, in name order, so two runs print the same lines. */
3551
+ function shippedSkills(source: string): string[] {
3552
+ if (!existsSync(source) || !statSync(source).isDirectory()) return [];
3553
+ return readdirSync(source)
3554
+ .filter((name) => statSync(join(source, name)).isDirectory() && existsSync(join(source, name, 'SKILL.md')))
3555
+ .sort();
3556
+ }
3557
+
3558
+ /**
3559
+ * The real path of `path` whether or not it exists yet: the real path of its
3560
+ * nearest existing ancestor with the rest appended. A relative link has to be
3561
+ * computed between two paths spelled the same way, and on macOS the temp
3562
+ * directory alone is reached as `/var/…` and is really `/private/var/…`.
3563
+ */
3564
+ function realpathAhead(path: string): string {
3565
+ const rest: string[] = [];
3566
+ let at = resolve(path);
3567
+ while (!existsSync(at)) {
3568
+ const up = dirname(at);
3569
+ if (up === at) break;
3570
+ rest.unshift(basename(at));
3571
+ at = up;
3572
+ }
3573
+ return join(realpathSync(at), ...rest);
3574
+ }
3575
+
3576
+ /** Every file under `root`, relative and sorted, so two trees compare in one order. */
3577
+ function filesUnder(root: string, prefix = ''): string[] {
3578
+ const out: string[] = [];
3579
+ for (const name of readdirSync(join(root, prefix)).sort()) {
3580
+ const rel = prefix === '' ? name : `${prefix}/${name}`;
3581
+ if (lstatSync(join(root, rel)).isDirectory()) out.push(...filesUnder(root, rel));
3582
+ else out.push(rel);
3583
+ }
3584
+ return out;
3585
+ }
3586
+
3587
+ /** The first way `copy` differs from `original`, or null when every file is the same bytes. */
3588
+ function firstDifference(copy: string, original: string): string | null {
3589
+ const theirs = filesUnder(copy);
3590
+ const ours = filesUnder(original);
3591
+ for (const rel of ours) {
3592
+ if (!theirs.includes(rel)) return `${rel} is missing from it`;
3593
+ if (!readFileSync(join(copy, rel)).equals(readFileSync(join(original, rel)))) return `${rel} differs`;
3594
+ }
3595
+ for (const rel of theirs) if (!ours.includes(rel)) return `${rel} is in it and not in the package`;
3596
+ return null;
3597
+ }
3598
+
3599
+ /**
3600
+ * Install every shipped skill into `dir`, or refuse and write nothing.
3601
+ *
3602
+ * The plan is made in full before the first write: every entry is classified as
3603
+ * absent, already this command's, or in the way, and one entry in the way
3604
+ * refuses the whole call with every such entry named.
3605
+ */
3606
+ function installSkills(source: string, dir: string, copy: boolean): SkillsInstallEntry[] {
3607
+ const names = shippedSkills(source);
3608
+ if (names.length === 0) {
3609
+ throw new SkillsInstallError(
3610
+ `no skill to install: ${source} ${existsSync(source) ? 'holds no <name>/SKILL.md' : 'is not there'}, and it is ` +
3611
+ 'the skills/ directory of the package this command ran from; nothing was written',
3612
+ );
3613
+ }
3614
+ if (existsSync(dir) && !statSync(dir).isDirectory()) {
3615
+ throw new SkillsInstallError(`${dir} exists and is not a directory, so no skill can be installed into it; nothing was written`);
3616
+ }
3617
+ const realDir = realpathAhead(dir);
3618
+ const planned: SkillsInstallEntry[] = [];
3619
+ const refused: string[] = [];
3620
+ for (const name of names) {
3621
+ const target = join(dir, name);
3622
+ const from = join(source, name);
3623
+ const realFrom = realpathSync(from);
3624
+ const link = relative(realDir, realFrom);
3625
+ let action: SkillsInstallAction = copy ? 'copied' : 'linked';
3626
+ let found: string | null = null;
3627
+ const stat = existsSync(target) || isLink(target) ? lstatSync(target) : null;
3628
+ if (stat === null) {
3629
+ // absent: this command writes it
3630
+ } else if (stat.isSymbolicLink()) {
3631
+ const text = readlinkSync(target);
3632
+ const pointsAt = resolve(realDir, text);
3633
+ const lands = existsSync(pointsAt) ? realpathSync(pointsAt) : null;
3634
+ if (lands === realFrom && !copy) action = 'already linked';
3635
+ else if (lands === realFrom) found = `a symlink to ${text}, the package's own folder, and --copy asks for a directory in its place`;
3636
+ else found = `a symlink to ${text}, ${lands === null ? 'which resolves to nothing' : `which resolves to ${lands}`}`;
3637
+ } else if (stat.isDirectory()) {
3638
+ const difference = copy ? firstDifference(target, from) : null;
3639
+ if (!copy) found = 'a directory';
3640
+ else if (difference === null) action = 'already copied';
3641
+ else found = `a directory that is not the package's copy (${difference})`;
3642
+ } else {
3643
+ found = 'a plain file';
3644
+ }
3645
+ if (found !== null) refused.push(`${target} is ${found}; ${copy ? `a copy of ${from}` : `a symlink to ${link}`} was required`);
3646
+ else planned.push({ target, source: from, action, link });
3647
+ }
3648
+ if (refused.length > 0) {
3649
+ throw new SkillsInstallError(
3650
+ `${refused.length} of the ${names.length} skill(s) cannot be installed into ${dir}, and nothing was written:\n` +
3651
+ refused.map((line) => ` ${line}`).join('\n') +
3652
+ '\nRemove the entries named above, or pass --dir to install somewhere else.',
3653
+ );
3654
+ }
3655
+ mkdirSync(dir, { recursive: true });
3656
+ for (const entry of planned) {
3657
+ if (entry.action === 'linked') symlinkSync(entry.link, entry.target, 'dir');
3658
+ else if (entry.action === 'copied') cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true, force: false });
3659
+ }
3660
+ return planned;
3661
+ }
3662
+
3663
+ /** A dangling link is not `existsSync`, and is still an entry in the way. */
3664
+ function isLink(path: string): boolean {
3665
+ try {
3666
+ return lstatSync(path).isSymbolicLink();
3667
+ } catch {
3668
+ return false;
3669
+ }
3670
+ }
3671
+
3672
+ function cmdSkills(flags: Record<string, string>, positional: string[]): void {
3673
+ const [sub, ...extra] = positional;
3674
+ if (sub === undefined) {
3675
+ throw new UsageError(
3676
+ `skills takes a subcommand: ${SKILLS_SUBCOMMANDS.join(', ')} — \`rigc skills install\` links every skill this ` +
3677
+ `package ships into ${DEFAULT_SKILLS_DIR}`,
3678
+ );
3679
+ }
3680
+ if (!SKILLS_SUBCOMMANDS.includes(sub)) {
3681
+ throw new UsageError(`unknown skills subcommand: ${sub} (rigc skills offers ${SKILLS_SUBCOMMANDS.join(', ')})`);
3682
+ }
3683
+ if (extra.length > 0) {
3684
+ throw new UsageError(
3685
+ `skills install takes no positional argument, and ${JSON.stringify(extra[0])} was given — the directory is --dir <path>`,
3686
+ );
3687
+ }
3688
+ const takes = COMMANDS.find((c) => c.name === 'skills')?.flags ?? [];
3689
+ const foreign = Object.keys(flags).filter((flag) => !takes.includes(flag));
3690
+ if (foreign.length > 0) {
3691
+ throw new UsageError(
3692
+ `skills install takes ${takes.map((flag) => `--${flag}`).join(' and ')}; ` +
3693
+ `${foreign.map((flag) => `--${flag}`).join(', ')} is not one of them`,
3694
+ );
3695
+ }
3696
+ const copy = flags.copy !== undefined;
3697
+ const dir = resolve(process.cwd(), flags.dir ?? DEFAULT_SKILLS_DIR);
3698
+ const entries = installSkills(join(import.meta.dir, 'skills'), dir, copy);
3699
+ for (const entry of entries) {
3700
+ const ends = entry.action.endsWith('linked') ? `${entry.target} -> ${entry.link} (${entry.source})` : `${entry.target} <- ${entry.source}`;
3701
+ console.log(` ${entry.action.padEnd(14)} ${ends}`);
3702
+ }
3703
+ const wrote = entries.filter((entry) => entry.action === 'linked' || entry.action === 'copied').length;
3704
+ const verb = copy ? 'copied' : 'linked';
3705
+ console.log(
3706
+ wrote === 0
3707
+ ? `rigc skills install: nothing to do — all ${entries.length} skill(s) are already ${verb} into ${dir}`
3708
+ : `rigc skills install: ${wrote} of ${entries.length} skill(s) ${verb} into ${dir}` +
3709
+ (wrote < entries.length ? `, ${entries.length - wrote} already there` : ''),
3710
+ );
3711
+ }
3712
+
3273
3713
  // ---------------------------------------------------------------------------
3274
3714
  // usage / per-command help
3275
3715
  // ---------------------------------------------------------------------------
@@ -3363,11 +3803,27 @@ const FLAG_MEANINGS: Record<string, string> = {
3363
3803
  'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
3364
3804
  'skeleton does not declare is refused with the ones it does. `render` records the skin in frames.json and ' +
3365
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',
3366
3814
  max: 'longest side of a rendered frame, in pixels (default 256)',
3367
3815
  record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
3368
3816
  ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
3369
3817
  ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
3370
3818
  again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
3819
+ dir:
3820
+ `the directory to install into, resolved against your working directory (default \`${DEFAULT_SKILLS_DIR}\`, the ` +
3821
+ 'workspace directory Codex, Gemini CLI and Antigravity read skills from)',
3822
+ copy:
3823
+ 'copy each skill folder instead of linking it, for a host that does not follow a linked skill folder. A copy ' +
3824
+ 'is not reached by an upgrade of the package, and one that is no longer the package\'s bytes is refused by name ' +
3825
+ 'on the next run — remove it and run again (default: a relative symlink, which an upgrade reaches with no ' +
3826
+ 'second run)',
3371
3827
  name: "the rig spec's own name, which the motion spec's archetype must match (default: the skeleton file's basename)",
3372
3828
  art: 'how the written spec reaches the art, which a skeleton does not encode: `loose` names an image per ' +
3373
3829
  "attachment, measured out of the rig spec's own images directory (--images writes it; without it, `build " +
@@ -3423,6 +3879,8 @@ const FLAG_VALUES: Record<string, string> = {
3423
3879
  'inward-lever': '<px>',
3424
3880
  animation: '<name>',
3425
3881
  skin: '<name>',
3882
+ slot: '<name[,name…]>',
3883
+ hide: '<name[,name…]>',
3426
3884
  max: '<px>',
3427
3885
  record: '<result.json>',
3428
3886
  ballot: '<ballot.html>',
@@ -3430,6 +3888,7 @@ const FLAG_VALUES: Record<string, string> = {
3430
3888
  name: '<n>',
3431
3889
  art: 'loose|none',
3432
3890
  stage: '<x,y,w,h>',
3891
+ dir: '<path>',
3433
3892
  };
3434
3893
 
3435
3894
  interface CommandDoc {
@@ -3572,7 +4031,7 @@ const COMMANDS: CommandDoc[] = [
3572
4031
  {
3573
4032
  name: 'check',
3574
4033
  usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
3575
- 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'],
3576
4035
  overrides: {
3577
4036
  skin: {
3578
4037
  meaning:
@@ -3581,6 +4040,16 @@ const COMMANDS: CommandDoc[] = [
3581
4040
  'contested art. The frames are checked back: a set whose frames.json records a different skin is ' +
3582
4041
  'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
3583
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
+ },
3584
4053
  },
3585
4054
  notes: [
3586
4055
  'every figure here is a reporting threshold, not a pass bar. Nothing in this report',
@@ -3623,8 +4092,9 @@ const COMMANDS: CommandDoc[] = [
3623
4092
  name: 'render',
3624
4093
  usage: [
3625
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)',
3626
4096
  ],
3627
- flags: ['candidate', 'atlas', 'animation', 'skin', 'fps', 'max', 'out'],
4097
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'out'],
3628
4098
  overrides: {
3629
4099
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
3630
4100
  fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
@@ -3632,9 +4102,22 @@ const COMMANDS: CommandDoc[] = [
3632
4102
  },
3633
4103
  {
3634
4104
  name: 'preview',
3635
- 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]'],
3636
4106
  flags: ['candidate', 'atlas', 'animation', 'out'],
3637
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
+ },
3638
4121
  out: {
3639
4122
  value: '<file>',
3640
4123
  meaning: 'the .html file to write (default `preview.html`); a directory means "the default name in here"',
@@ -3723,6 +4206,21 @@ const COMMANDS: CommandDoc[] = [
3723
4206
  },
3724
4207
  },
3725
4208
  },
4209
+ {
4210
+ name: 'skills',
4211
+ usage: [`rigc skills install [--dir ${DEFAULT_SKILLS_DIR}] [--copy] (every skill this package ships, where an agent host looks)`],
4212
+ flags: ['dir', 'copy'],
4213
+ notes: [
4214
+ 'the skills installed are the skills/ directory of the package this command runs from,',
4215
+ 'never whatever the working directory holds. Each becomes <dir>/<name>: a relative',
4216
+ 'symlink into that folder, or with --copy a copy of it. An entry already there that',
4217
+ 'is not a link to the same folder — or, with --copy, not the same bytes — is refused',
4218
+ 'by name, exit 1, and nothing is written; a run over only what this command made has',
4219
+ 'nothing to do and exits 0. Codex, Gemini CLI and Antigravity read',
4220
+ `<workspace>/${DEFAULT_SKILLS_DIR}; Claude Code installs the plugin instead (README,`,
4221
+ '"Install it into your agent").',
4222
+ ],
4223
+ },
3726
4224
  ];
3727
4225
 
3728
4226
  const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
@@ -3829,6 +4327,13 @@ const USAGE = [
3829
4327
  'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
3830
4328
  'refused by name instead of appended.',
3831
4329
  '',
4330
+ 'skills install puts the agent skills this package ships where an agent host looks',
4331
+ 'for them, since none of them reads node_modules:',
4332
+ ` rigc skills install relative links in ${DEFAULT_SKILLS_DIR}, which Codex, Gemini CLI`,
4333
+ ' and Antigravity read; --copy writes the folders instead',
4334
+ 'An entry already there that this command did not make is refused by name and',
4335
+ 'nothing is written; a second run has nothing to do. See `rigc skills --help`.',
4336
+ '',
3832
4337
  'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
3833
4338
  ' "manifest": "..." (optional) } }, with every path',
3834
4339
  'resolved relative to the cuts.json file itself.',
@@ -3866,10 +4371,11 @@ try {
3866
4371
  else if (command === 'bench') cmdBench(flags, positional);
3867
4372
  else if (command === 'bonedist') cmdBoneDist(flags);
3868
4373
  else if (command === 'render') cmdRender(flags);
3869
- else if (command === 'preview') cmdPreview(flags);
4374
+ else if (command === 'preview') cmdPreview(flags, lists.candidate ?? []);
3870
4375
  else if (command === 'pose') cmdPose(flags);
3871
4376
  else if (command === 'chainfit') cmdChainFit(flags);
3872
4377
  else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
4378
+ else if (command === 'skills') cmdSkills(flags, positional);
3873
4379
  } catch (err) {
3874
4380
  if (err instanceof UsageError) {
3875
4381
  console.error(`rigc: ${err.message}\n\n${USAGE}`);
@@ -3940,5 +4446,13 @@ try {
3940
4446
  console.error(`rigc: ${err.message}`);
3941
4447
  process.exit(1);
3942
4448
  }
4449
+ // An install refused before its first write (issue #831): the invocation was
4450
+ // fine and an entry on disk was not what this command would write, so exit 1
4451
+ // like a file that is not a PNG. The message names every such entry, what is
4452
+ // there and what was required; the usage under it would bury that.
4453
+ if (err instanceof SkillsInstallError) {
4454
+ console.error(`rigc skills install: ${err.message}`);
4455
+ process.exit(1);
4456
+ }
3943
4457
  throw err;
3944
4458
  }