spine-rigc 1.9.2 → 1.9.4

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.
Files changed (49) hide show
  1. package/cli.ts +28 -4397
  2. package/docs/AUTHORING.md +4 -4
  3. package/package.json +1 -1
  4. package/src/assertions/bodies/a06.ts +53 -3
  5. package/src/assertions/bodies/a13.ts +3 -3
  6. package/src/assertions/bodies/a14.ts +3 -3
  7. package/src/assertions/bodies/a15.ts +5 -5
  8. package/src/assertions/bodies/a19.ts +44 -2
  9. package/src/assertions/bodies/a22.ts +3 -3
  10. package/src/assertions/facts/animated_bones.ts +4 -2
  11. package/src/assertions/facts/mesh_attachments.ts +33 -4
  12. package/src/assertions/model/animated_bones.ts +13 -5
  13. package/src/assertions/model/bone_timelines.ts +5 -3
  14. package/src/assertions/model/declared.ts +127 -0
  15. package/src/assertions/model/index.ts +63 -40
  16. package/src/assertions/model/mesh_attachments.ts +31 -2
  17. package/src/assertions/model/parse.ts +9 -1
  18. package/src/assertions/model/runtime_timelines.ts +78 -0
  19. package/src/assertions/model/slider_composition.ts +10 -8
  20. package/src/assertions/model/stepped_poses.ts +3 -2
  21. package/src/bonedist.ts +4 -5
  22. package/src/check.ts +3 -2
  23. package/src/checkpics.ts +1 -1
  24. package/src/cli/core_commands.ts +1532 -0
  25. package/src/cli/shared.ts +1935 -0
  26. package/src/cli/spine_commands.ts +1141 -0
  27. package/src/core/additive.ts +52 -62
  28. package/src/core/constraints.ts +26 -3
  29. package/src/core/constraints_physics.ts +11 -1
  30. package/src/core/constraints_slider.ts +136 -6
  31. package/src/core/index.ts +27 -3
  32. package/src/core/skins.ts +28 -3
  33. package/src/core/walk.ts +9 -36
  34. package/src/correspondence.ts +15 -0
  35. package/src/deformbuild.ts +60 -0
  36. package/src/deformmeasure.ts +20 -48
  37. package/src/deformreport.ts +2 -2
  38. package/src/framing.ts +1 -1
  39. package/src/pose.ts +1 -1
  40. package/src/preview.ts +1 -1
  41. package/src/region_joins.ts +54 -0
  42. package/src/render.ts +206 -2836
  43. package/src/render_core.ts +19 -36
  44. package/src/render_shared.ts +2887 -0
  45. package/src/slots.ts +1 -1
  46. package/src/spine_side.ts +123 -0
  47. package/src/validate.ts +11 -91
  48. package/src/assertions/facts/skin_meshes.ts +0 -49
  49. package/src/assertions/model/skin_meshes.ts +0 -90
@@ -0,0 +1,1935 @@
1
+ /**
2
+ * What every rigc entry shares (issue #1052, step 4e of #380): the argument
3
+ * parser, the readers of the files a command line names, the helpers more
4
+ * than one command calls, every command's documentation and the dispatch —
5
+ * moved here unchanged from `cli.ts`, which is now one entry over it.
6
+ *
7
+ * ⭐ A command is documented once (`COMMANDS`), and the documentation says,
8
+ * as data, what the command's body needs (`runtime`, `spineFormat`). An entry
9
+ * is the documentation plus the bodies it registers (`runCli`): `cli.ts`
10
+ * registers every command, and `cli_core.ts` registers only those whose
11
+ * `runtime` is `false` — its set is read off this table, its `--help` prints
12
+ * that set, and a command outside it is refused naming what it needs the
13
+ * runtime for. Nothing in this file links spine-core, and neither does
14
+ * anything it imports: the bodies that reach the runtime are
15
+ * `./spine_commands.ts`'s, which only `cli.ts` imports.
16
+ */
17
+ import { DEFAULT_PADDING, DEFAULT_PAGE_EDGES, DEFAULT_PAGE_SIZE } from '../atlas.ts';
18
+ import { MAX_CANDIDATES, MIN_CANDIDATES } from '../ballot.ts';
19
+ import { BONEDIST_SPEC, IDENTITY_CORRESPONDENCE } from '../correspondence.ts';
20
+ import { ANCHOR_MAX_RESIDUAL, ANCHOR_MAX_UNEXPLAINED, DEFAULT_HINGE_MAX, DEFAULT_HINGE_MIN, DEFAULT_MIN_LEVER_PX, DEFAULT_MIN_VISIBLE, DEFAULT_PASSES } from '../chainfit.ts';
21
+ import { checkAgainstFrames, type CheckOptions, CheckPlates, type CheckReport } from '../check.ts';
22
+ import { CompileError, droppedStateReason, type CompileOptions } from '../compile.ts';
23
+ import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
24
+ import { parseJsonWithPosition } from '../json-position.ts';
25
+ import { RUNG_IDS } from '../ladder.ts';
26
+ import { MODEL_DOCUMENT_FILE } from '../model.ts';
27
+ import { DEFAULT_MAX_RESIDUAL, DEFAULT_SCALE_MAX, DEFAULT_SCALE_MIN } from '../pose.ts';
28
+ import {
29
+ CandidateAtlasError,
30
+ CandidatePairError,
31
+ GEOMETRY_FILE,
32
+ GeometryError,
33
+ POSER_NAMES,
34
+ PoserChoiceError,
35
+ type PoserName,
36
+ PROTOCOL_FPS,
37
+ UnframeablePoseError,
38
+ } from '../render_shared.ts';
39
+ import { BallotError } from '../ballot.ts';
40
+ import { ChainFitError } from '../chainfit.ts';
41
+ import { CheckError } from '../check.ts';
42
+ import { IngestError } from '../ingest.ts';
43
+ import { NotAPngError } from '../png.ts';
44
+ import { PoseError } from '../pose.ts';
45
+ import { SpineRuntimeError, SPINE_SIDE_ABSENT } from '../spine_side.ts';
46
+ import type { CompileResult, DroppedState } from '../types.ts';
47
+ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
48
+ import { dirname, join, resolve } from 'node:path';
49
+
50
+
51
+ /**
52
+ * One entry of a cuts.json, every path relative to the cuts.json file.
53
+ *
54
+ * `rig` is required — it is the skeleton's structure, and until it was a file
55
+ * that structure was three hard-coded tables in the compiler. `manifest` is
56
+ * optional: a skeleton with no measured art behind it has none, and then the rig
57
+ * spec carries its own attachments and stage size.
58
+ */
59
+ export interface CutEntry {
60
+ rig: string;
61
+ motion: string;
62
+ out: string;
63
+ manifest?: string;
64
+ /** Base directory for the rig spec's `image` references, if not the rig's own. */
65
+ images?: string;
66
+ }
67
+
68
+ export type CutTable = Record<string, CutEntry>;
69
+
70
+ export class UsageError extends Error {}
71
+
72
+ /**
73
+ * `explain` refusing a pair it cannot pose — a usage error in kind, printed
74
+ * without the usage block for `PoseError`'s and `IngestError`'s reason: the
75
+ * message names an attachment, a region and two flags, and reprinting every
76
+ * command's usage under it buries the one line that says what to change.
77
+ */
78
+ export class ExplainError extends Error {}
79
+
80
+ // ---------------------------------------------------------------------------
81
+ // package metadata — the installed version and repository, for `--version`
82
+ // and for naming a remedy `bench` can only give from a repo checkout.
83
+ // ---------------------------------------------------------------------------
84
+
85
+ interface PackageMeta {
86
+ version?: string;
87
+ repository?: string | { url?: string };
88
+ }
89
+
90
+ let packageMeta: PackageMeta | null | undefined;
91
+
92
+ /**
93
+ * The directory `cli.ts` sits in: `package.json`, `skills/` and, in a
94
+ * checkout, `examples/` and `scripts/` are beside it, in the repository and
95
+ * once installed. Two levels above this file (`src/cli/`), which is where the
96
+ * commands that read those live since issue #1052 — every path they built off
97
+ * `cli.ts`'s own directory is built off this one, and comes out the same.
98
+ */
99
+ export const PACKAGE_ROOT = join(import.meta.dir, '..', '..');
100
+
101
+ /** `package.json` sits next to `cli.ts` both in the repo and once installed (`PACKAGE_ROOT`). */
102
+ export function readPackageMeta(): PackageMeta | null {
103
+ if (packageMeta === undefined) {
104
+ try {
105
+ packageMeta = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')) as PackageMeta;
106
+ } catch {
107
+ packageMeta = null;
108
+ }
109
+ }
110
+ return packageMeta;
111
+ }
112
+
113
+ export function readVersion(): string {
114
+ return readPackageMeta()?.version ?? 'unknown';
115
+ }
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // argument parsing
119
+ // ---------------------------------------------------------------------------
120
+
121
+ /**
122
+ * The flags that are switches rather than `--flag value` pairs.
123
+ *
124
+ * Listed by name rather than inferred from "the next argument looks like a
125
+ * flag": inferring it would turn `--out --json report.json` — a real typo, a
126
+ * missing value — into a silently accepted switch plus a stray positional.
127
+ *
128
+ * ⚠️ This set and `FLAG_VALUES` are two halves of one statement, and they are
129
+ * the halves a reader and the parser read separately: a flag absent from
130
+ * `FLAG_VALUES` is printed bare in every usage line and flag table, and a flag
131
+ * present here is the only kind the parser will accept bare. `all-bones` was in
132
+ * one half and not the other for two releases — documented bare in `bonedist`'s
133
+ * usage line, in the shared flag table, and in the hint `src/bonedist.ts` prints
134
+ * under a truncated bone table, while the parser fell through to the value
135
+ * branch and answered the caller who followed that hint with `rigc: --all-bones
136
+ * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
137
+ * halves together by reading `--help` rather than by naming a flag.
138
+ */
139
+ const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry']);
140
+
141
+ /**
142
+ * The flags a command is allowed to spell more than once.
143
+ *
144
+ * `vote --candidate` is, because a ballot is *by definition* several
145
+ * candidates; `preview --candidate` is, because a pane per candidate on one
146
+ * page is what an agent otherwise builds by hand (issue #837); and `diff --as`
147
+ * 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
148
+ * (issue #720). Everywhere else a repeat is a mistake and is refused: `check
149
+ * --candidate a --candidate b` used to take `b` silently, which is a report
150
+ * about a rig the caller did not think they were asking about.
151
+ */
152
+ export const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
153
+ vote: new Set(['candidate']),
154
+ diff: new Set(['as']),
155
+ preview: new Set(['candidate']),
156
+ };
157
+
158
+ /**
159
+ * `--flag value` pairs plus the leftover positionals, in order.
160
+ *
161
+ * `lists` carries every occurrence of every flag and `flags` carries the last
162
+ * one, so a command that wants a repeated flag reads `lists` and the ones that
163
+ * do not are untouched by the addition.
164
+ */
165
+ export function parseArgs(
166
+ argv: string[],
167
+ repeatable: ReadonlySet<string> = new Set(),
168
+ ): { flags: Record<string, string>; lists: Record<string, string[]>; positional: string[] } {
169
+ const flags: Record<string, string> = {};
170
+ const lists: Record<string, string[]> = {};
171
+ const positional: string[] = [];
172
+ const take = (name: string, value: string): void => {
173
+ if (flags[name] !== undefined && !repeatable.has(name)) {
174
+ throw new UsageError(
175
+ `--${name} was given more than once (${JSON.stringify(flags[name])} then ${JSON.stringify(value)}); ` +
176
+ 'this command takes it once',
177
+ );
178
+ }
179
+ flags[name] = value;
180
+ (lists[name] ??= []).push(value);
181
+ };
182
+ for (let i = 0; i < argv.length; i++) {
183
+ const arg = argv[i];
184
+ if (arg.startsWith('--')) {
185
+ const eq = arg.indexOf('=');
186
+ if (eq !== -1) {
187
+ take(arg.slice(2, eq), arg.slice(eq + 1));
188
+ } else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
189
+ take(arg.slice(2), 'true');
190
+ } else {
191
+ const next = argv[i + 1];
192
+ if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
193
+ take(arg.slice(2), next);
194
+ i++;
195
+ }
196
+ } else {
197
+ positional.push(arg);
198
+ }
199
+ }
200
+ return { flags, lists, positional };
201
+ }
202
+
203
+ /**
204
+ * Read and parse a JSON file the caller named on the command line — a cuts
205
+ * table, a candidate or reference skeleton to `diff`. A parse failure names the
206
+ * file and, best-effort, where inside it the syntax broke (see
207
+ * `parseJsonWithPosition`); left as a raw `JSON.parse`, it would surface as an
208
+ * unhandled `SyntaxError` with a stack trace instead of a usage error.
209
+ */
210
+ export function readJsonFile(path: string): unknown {
211
+ return parseJsonNamed(readFileSync(path, 'utf8'), path);
212
+ }
213
+
214
+ /** `text`, read from `path`, parsed — and refused naming the file and where it broke when it is not JSON. */
215
+ export function parseJsonNamed(text: string, path: string): unknown {
216
+ try {
217
+ return parseJsonWithPosition(text);
218
+ } catch (err) {
219
+ throw new UsageError(`cannot read ${path}: ${(err as Error).message}`);
220
+ }
221
+ }
222
+
223
+ /**
224
+ * A skeleton a command was pointed at, as text — refused like every other JSON
225
+ * file on the command line (`parseJsonNamed`) when it is not JSON (issue
226
+ * #1042). `render`, `check`, `bench` and `bonedist` hand the text to a loader
227
+ * that parses it again, and on a file that is not JSON that parse surfaced as
228
+ * the runtime's `SyntaxError` and a stack, where `diff`, `preview`, `vote` and
229
+ * `ingest` already said `cannot read <path>`.
230
+ */
231
+ export function readSkeletonText(path: string): string {
232
+ const text = readFileSync(path, 'utf8');
233
+ parseJsonNamed(text, path);
234
+ return text;
235
+ }
236
+
237
+ /**
238
+ * Read a cuts.json and resolve its three paths against the file's own
239
+ * directory. Anchoring on the table rather than on the process cwd is what lets
240
+ * the same command work from anywhere in the owning project.
241
+ */
242
+ function readCutTable(cutsPath: string): { dir: string; table: CutTable } {
243
+ const abs = resolve(cutsPath);
244
+ if (!existsSync(abs)) throw new UsageError(`no cuts file at ${abs}`);
245
+ const parsed = readJsonFile(abs);
246
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
247
+ throw new UsageError(`${abs}: expected an object of cut name -> { manifest, motion, out }`);
248
+ }
249
+ return { dir: dirname(abs), table: parsed as CutTable };
250
+ }
251
+
252
+ function entryToOptions(dir: string, name: string, entry: CutEntry): CompileOptions {
253
+ for (const key of ['rig', 'motion', 'out'] as const) {
254
+ if (typeof entry?.[key] !== 'string') throw new UsageError(`cut ${JSON.stringify(name)} has no "${key}" path`);
255
+ }
256
+ const opts: CompileOptions = {
257
+ rigPath: resolve(dir, entry.rig),
258
+ motionPath: resolve(dir, entry.motion),
259
+ outDir: resolve(dir, entry.out),
260
+ };
261
+ if (entry.manifest !== undefined) opts.manifestPath = resolve(dir, entry.manifest);
262
+ if (entry.images !== undefined) opts.imagesDir = resolve(dir, entry.images);
263
+ return opts;
264
+ }
265
+
266
+ /**
267
+ * Resolve the cut a command was pointed at, either spelled out on the command
268
+ * line or looked up by name in a cuts.json.
269
+ */
270
+ export function resolveCut(flags: Record<string, string>): { label: string; opts: CompileOptions } {
271
+ const explicit =
272
+ flags.rig !== undefined || flags.manifest !== undefined || flags.motion !== undefined || flags.out !== undefined;
273
+ if (explicit) {
274
+ if (flags.cut !== undefined || flags.cuts !== undefined) {
275
+ throw new UsageError('--rig/--motion/--out and --cut/--cuts are two ways to say the same thing; pick one');
276
+ }
277
+ for (const key of ['rig', 'motion', 'out'] as const) {
278
+ if (flags[key] === undefined) throw new UsageError(`--${key} is required when the cut is spelled out`);
279
+ }
280
+ const opts: CompileOptions = {
281
+ rigPath: resolve(flags.rig),
282
+ motionPath: resolve(flags.motion),
283
+ outDir: resolve(flags.out),
284
+ };
285
+ if (flags.manifest !== undefined) opts.manifestPath = resolve(flags.manifest);
286
+ if (flags.images !== undefined) opts.imagesDir = resolve(flags.images);
287
+ if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
288
+ return { label: flags.rig, opts };
289
+ }
290
+ if (flags.cut === undefined) throw new UsageError('give either --cut <name> --cuts <cuts.json>, or --rig/--motion/--out');
291
+ if (flags.cuts === undefined) throw new UsageError('--cut needs --cuts <cuts.json> to look the name up in');
292
+ const { dir, table } = readCutTable(flags.cuts);
293
+ const entry = table[flags.cut];
294
+ if (!entry) {
295
+ throw new UsageError(
296
+ `unknown cut ${JSON.stringify(flags.cut)} in ${resolve(flags.cuts)}. known: ${Object.keys(table).join(', ') || '(none)'}`,
297
+ );
298
+ }
299
+ const opts = entryToOptions(dir, flags.cut, entry);
300
+ // `--atlas-in` is not part of the cuts table: a cut names its rig, motion and
301
+ // manifest, and where the pixels are delivered from is a property of the BUILD.
302
+ // Resolved against the working directory, like every other path on the command
303
+ // line, rather than against the table's directory.
304
+ if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
305
+ return { label: flags.cut, opts };
306
+ }
307
+
308
+ /**
309
+ * One line per mesh kind on this cut, printed above the table.
310
+ *
311
+ * A legend rather than a heading: the heading used to describe the ring tier
312
+ * unconditionally, so a build whose only mesh was a ribbon or a contour got a
313
+ * sentence about a rim ring and a seam it does not have.
314
+ */
315
+ /**
316
+ * What a depth map and a soft region put on a mesh, when it named either.
317
+ *
318
+ * The digests are the reason this prints at all: a claim about a rig can name
319
+ * WHICH sheet produced it, and two runs a reader believes differ can be shown to
320
+ * have read the same pixels. The ranges and counts are what say the input
321
+ * reached the geometry rather than merely being resolved — a `carried 0` never
322
+ * gets here (it is refused) and a `ramped 0` is a hard-edged mask, which is
323
+ * legal and usually not what somebody meant.
324
+ */
325
+ /**
326
+ * One axis's two ceilings, as `+31.41 / -18.03`, or what is unbounded on it.
327
+ *
328
+ * ⚠️ `none` and a number are different claims and are printed differently. A
329
+ * sheet with no gradient along an axis cannot fold anything on it AT ANY ANGLE,
330
+ * which is a fact about the sheet worth reading; printing `90` for it would be
331
+ * a limit nothing measured.
332
+ */
333
+ function ceilingPair(axis: { positive: FoldLimit | null; negative: FoldLimit | null }): string {
334
+ const one = (l: FoldLimit | null, sign: string) => (l === null ? `${sign}none` : `${sign}${l.degrees.toFixed(2)}°`);
335
+ return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
336
+ }
337
+
338
+ /**
339
+ * The same axis's two 1st percentiles, each with its ratio to the ceiling above
340
+ * it and the population it came out of — `+64.80° x1.003 of 5988`.
341
+ *
342
+ * ⭐ The ratio is the whole point and it is printed rather than judged. A
343
+ * ceiling set by the FORM is the floor of a band: the steepest region of a
344
+ * smooth sheet has area, so the 1st percentile sits a fraction of a percent
345
+ * above the minimum. A ceiling set by one bad texel has 99 % of the mesh
346
+ * surviving to the form's angle while the reported number collapses — 64.58°
347
+ * against 6.08° for one texel of 160,000, with the percentile unmoved at 64.80°
348
+ * in both ([#412](https://github.com/firejune/rigc/issues/412),
349
+ * `bench/studies/2026-09-05-noise` §6).
350
+ *
351
+ * Three spellings, three different claims, for the reason `ceilingPair` prints
352
+ * `none` rather than 90: `+none` is a side nothing folds on at all, `+unranked
353
+ * of 36` is a side whose population is too small for a first percentile to be
354
+ * anything but the minimum itself, and a number is a measurement.
355
+ *
356
+ * ⛔ No threshold lives here. What ratio means what is in `docs/AUTHORING.md`
357
+ * §3.4, because a number rigc printed an adjective beside would be a policy the
358
+ * compiler invented out of a measurement — and `A39` would go on refusing at the
359
+ * raw angle either way.
360
+ */
361
+ function spreadPair(axis: { positive: FoldLimit | null; negative: FoldLimit | null }): string {
362
+ const one = (l: FoldLimit | null, sign: string) =>
363
+ l === null
364
+ ? `${sign}none`
365
+ : l.p1 === null
366
+ ? `${sign}unranked of ${l.count}`
367
+ : `${sign}${l.p1.toFixed(2)}° x${(l.p1 / l.degrees).toFixed(3)} of ${l.count}`;
368
+ return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
369
+ }
370
+
371
+ /** The tightest of the four, so the line that names a triangle names the right one. */
372
+ function tightestFold(c: TurnCeiling): { kind: string; sign: string; limit: FoldLimit } | null {
373
+ const all = [
374
+ { kind: 'yaw', sign: '+', limit: c.yaw.positive },
375
+ { kind: 'yaw', sign: '-', limit: c.yaw.negative },
376
+ { kind: 'pitch', sign: '+', limit: c.pitch.positive },
377
+ { kind: 'pitch', sign: '-', limit: c.pitch.negative },
378
+ ].filter((e): e is { kind: string; sign: string; limit: FoldLimit } => e.limit !== null);
379
+ if (all.length === 0) return null;
380
+ return all.reduce((best, e) => (e.limit.degrees < best.limit.degrees ? e : best));
381
+ }
382
+
383
+ export function meshDepthNote(m: CompileResult['meshes'][number]): string {
384
+ const parts: string[] = [];
385
+ if (m.depth) {
386
+ parts.push(
387
+ `depth "${m.depth.image}" ${m.depth.digest} near=${m.depth.near} zScale=${m.depth.zScale} ` +
388
+ `z=[${m.depth.range[0]}, ${m.depth.range[1]}]`,
389
+ );
390
+ // Directly under the sheet's own line, because it is the other half of
391
+ // "what did this mesh read": `z=[…]` says how much of the map's range
392
+ // reached the vertices, and this says how many of them read a texel that
393
+ // draws nothing (issue #449). Reported only when there is something to
394
+ // report, so a mesh whose every vertex sits on drawn art gains no line.
395
+ //
396
+ // ⭐ A `contour` gets the OTHER half of the sentence, because rigc built
397
+ // that outline and knows what it is: `buildContourMesh` returns
398
+ // `hullVertices: points.length` over `offsetPolygon(simplified, margin)`,
399
+ // so every vertex of a contour mesh is the traced silhouette pushed out by
400
+ // the margin — there are no interior vertices for the count to be about.
401
+ // Nothing is derived, inferred or thresholded to say so; it is what the
402
+ // generator returns, and `generatedHullAndEdges` already cross-checks that
403
+ // hull against the triangulation's own outline. Without it the line reads
404
+ // as a fault on every correct contour rig, which is a diagnostic authors
405
+ // learn to ignore.
406
+ // Withheld, and said where the count would have stood (issue #750): the
407
+ // part's texels could not be located on its page, so there is no count
408
+ // that is about this part.
409
+ if (m.depth.unlocated !== undefined) {
410
+ parts.push(`the count of vertices on undrawn texels is not measured: ${m.depth.unlocated}. ${PAGE_GRID_UNLOCATED}`);
411
+ } else if (m.depth.undrawn !== null && m.depth.undrawn > 0) {
412
+ parts.push(
413
+ `${m.depth.undrawn} of ${m.vertices} vertices sample a texel the part image does not draw — ` +
414
+ (m.kind === 'contour'
415
+ ? 'a contour\'s vertices are all traced outline, pushed out by the margin, so this is the topology and not the sheet'
416
+ : 'their z is the sheet\'s reading of somewhere the part is not'),
417
+ );
418
+ }
419
+ const c = m.depth.ceiling;
420
+ parts.push(`turn ceiling yaw ${ceilingPair(c.yaw)} pitch ${ceilingPair(c.pitch)}`);
421
+ const worst = tightestFold(c);
422
+ if (worst !== null) {
423
+ parts.push(` 1st pct yaw ${spreadPair(c.yaw)} pitch ${spreadPair(c.pitch)}`);
424
+ }
425
+ parts.push(
426
+ worst === null
427
+ ? ` nothing in this sheet folds: ${c.measured} triangle(s) measured, none with a depth gradient across it`
428
+ : ` first to fold: ${worst.kind} ${worst.sign} at ${worst.limit.degrees.toFixed(2)}°, ` +
429
+ `triangle ${worst.limit.triangle} [${worst.limit.ids.join(',')}], the sheet steps ` +
430
+ `${depthStepLevels(worst.limit.depthStep, m.depth.zScale).toFixed(2)} level(s) across it, ` +
431
+ // The same step over the range the mesh sampled (issue #448). A
432
+ // suffix and not a line of its own: it is an apposition on the step
433
+ // beside it, and the reading that matters is the two together — a
434
+ // discontinuity says "plenty of levels" and "nearly all of them" at
435
+ // once, and they have to be read in one breath to say the opposite.
436
+ `which is ${worst.limit.stepShare.toFixed(3)} of the range this mesh sampled` +
437
+ `${c.degenerate ? `; ${c.degenerate} triangle(s) too flat in setup to measure` : ''}`,
438
+ );
439
+ }
440
+ if (m.soft) {
441
+ parts.push(
442
+ `soft "${m.soft.mask}" ${m.soft.digest} -> ${m.soft.bone}, ${m.soft.carried} carried / ${m.soft.ramped} in the falloff`,
443
+ );
444
+ }
445
+ return parts.length === 0 ? '' : `\n ${parts.join('\n ')}`;
446
+ }
447
+
448
+ /**
449
+ * The line under a `segments` mesh: how its bones share the vertices.
450
+ *
451
+ * ⭐ The weights are this generator's whole output, and the per-vertex numbers
452
+ * are nowhere a reader can see them — so the three figures that say whether the
453
+ * falloff did what was meant are printed where the mesh is: the most bones any
454
+ * vertex binds (against `maxBones`), the mean, and how many vertices a single
455
+ * bone owns outright. A named bone no vertex binds is said by name, because it
456
+ * is in no weight and so in no other figure either.
457
+ */
458
+ export function meshInfluenceNote(m: CompileResult['meshes'][number]): string {
459
+ const inf = m.influence;
460
+ if (inf === undefined) return '';
461
+ const unbound = m.bones.filter((b) => !inf.bound.includes(b));
462
+ const single = m.vertices === 0 ? 0 : (inf.singleBone / m.vertices) * 100;
463
+ const joined = inf.keptCells - inf.artCells;
464
+ return (
465
+ `\n influence max ${inf.maxBones} bone(s) per vertex, mean ${inf.meanBones.toFixed(2)}, ` +
466
+ `${inf.singleBone} of ${m.vertices} vertices (${single.toFixed(2)}%) on a single bone` +
467
+ (unbound.length ? `; named and bound by no vertex: ${unbound.join(', ')}` : '') +
468
+ `\n lattice cell ${inf.cell}px, ${inf.cols}x${inf.rows} cells, ${inf.artCells} with art, ${inf.keptCells} kept` +
469
+ (inf.islands > 1 || joined > 0
470
+ ? ` (${inf.islands} island(s) joined into one outline, ${joined} cell(s) added that hold no art)`
471
+ : '')
472
+ );
473
+ }
474
+
475
+ /**
476
+ * Why a figure taken off a part's texels is withheld on a page whose file is
477
+ * not the size its atlas declares — the tail of every line that withholds one
478
+ * (issue #750). The page and its ratios come first, from `pageGridSaid`; this
479
+ * says what that does to the reading and where the repair is named.
480
+ */
481
+ export const PAGE_GRID_UNLOCATED =
482
+ 'rigc lifts a part off its page at the coordinates the atlas states, and on this file those are not where the ' +
483
+ "part's texels are, so a figure taken there would describe another part of the page. " +
484
+ '`A06_ATLAS_PAGE_SIZE_MATCHES_PNG` refuses the page by the same ratio and names the `scale:` header that states it';
485
+
486
+ /**
487
+ * What a mesh measured about its own fit against the art it names, or nothing
488
+ * for a mesh with no art to measure against.
489
+ *
490
+ * Printed for authored geometry as well as for a `contour` (issue #277): the
491
+ * figure is a measurement between the emitted triangles and the PNG, so it means
492
+ * the same thing whoever drew the vertices, and the silence was the defect —
493
+ * an octagon rim placed on a round part's silhouette clips its own ink outline
494
+ * at 94.31% and used to print nothing at all.
495
+ *
496
+ * The hole is appended only when there is one, so the common line is unchanged.
497
+ * It is the one figure in the report that a hole moves: `coverage` and
498
+ * `overshoot` are both measured against the FILLED silhouette, so spanning an
499
+ * interior hole is neither missing coverage nor reaching past anything, and an
500
+ * unintentional hole — a gap in the art, a stroke that failed to join — bought
501
+ * fill over transparent pixels with nothing anywhere saying so (issue #275).
502
+ */
503
+ export function meshFit(m: CompileResult['meshes'][number]): string {
504
+ // The fit is a measurement against the part's texels, and on a page whose
505
+ // file is not its declared size the region lift does not have them (issue
506
+ // #750). It printed 68.49% / 76.24px there for a mesh that measures 100.00% /
507
+ // 16.00px on the page it was packed from — two plausible numbers about
508
+ // another part of the picture. So the line says what was not measured and
509
+ // why, in the place the figures stood, and prints no figure.
510
+ if (m.fitWithheld !== undefined) return ` fit not measured: ${m.fitWithheld}. ${PAGE_GRID_UNLOCATED}`;
511
+ if (m.coverage === undefined) return '';
512
+ // A count of the plate's own cells, so on a `scale:` page it is texels and
513
+ // says so rather than borrowing the overshoot's unit beside it (issue #762).
514
+ const hole = m.holePixels ? `, enclosing ${m.holePixels}${m.pageScale === undefined ? 'px' : ' texel(s)'} of hole` : '';
515
+ return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${meshFitGrid(m)}${hole}`;
516
+ }
517
+
518
+ /**
519
+ * The grid a fit was taken on, when it is not the drawing's own (issue #762).
520
+ *
521
+ * The overshoot is stated in the drawing's pixels on every route — the unit an
522
+ * attachment's size is in — and on a page that declares a `scale:` other than
523
+ * 1 it was measured on the page's texels and divided by that scale. So it
524
+ * carries the coarser grid's step: on `scale: 0.5` a figure moves in steps of
525
+ * 2.00px of the drawing, and it need not equal the figure the page it was
526
+ * packed from reads except where the distance falls on whole texels. Said
527
+ * beside the figure, and nothing at all on a loose part or a page at scale 1,
528
+ * where the line is the one it always was.
529
+ */
530
+ function meshFitGrid(m: CompileResult['meshes'][number]): string {
531
+ if (m.pageScale === undefined) return '';
532
+ return (
533
+ ` (the drawing's pixels, measured on the page's texels at scale: ${m.pageScale} — a texel is ` +
534
+ `${(1 / m.pageScale).toFixed(2)}px of the drawing)`
535
+ );
536
+ }
537
+
538
+ /**
539
+ * The triangle budget a `MESH` line is read against: the rig's, or nothing.
540
+ *
541
+ * 📐 It used to be the literal `80`, which was nobody's budget — the rig quoted
542
+ * in issue #275 declared 64, `A13_MESH_BUDGET` measured against that 64
543
+ * correctly, and the line an author actually reads printed 80. Under the default
544
+ * `--profile spine` `A13` is `PROF`, so the printed number is the only budget
545
+ * figure in the output and it has to be the declared one. A rig that declares
546
+ * none says so in the same words `A13` SKIPs in, rather than being given a wall.
547
+ */
548
+ export function meshBudget(rig: CompileResult['rig']): string {
549
+ return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
550
+ }
551
+
552
+ /**
553
+ * Where a pair of artifacts lives, given what the caller pointed at.
554
+ *
555
+ * Two shapes, because rigc's own output and a foreign export are named
556
+ * differently and both have to be gateable. rigc writes `skeleton.json` +
557
+ * `skeleton.atlas` into a directory. Everybody else writes whatever the editor
558
+ * called the project, and the official examples are not even consistent with
559
+ * themselves — `7-anticipation/export/` holds `sack-pro.json`, `spineboy/export/`
560
+ * holds two skeletons and two atlases.
561
+ *
562
+ * ⚠️ When more than one atlas sits beside the skeleton, this refuses to choose.
563
+ * Guessing by name would be wrong on the corpus that motivated it: `spineboy-ess`
564
+ * shares a longer prefix with `spineboy-run.atlas` than with the `spineboy.atlas`
565
+ * it actually uses, so the plausible heuristic picks the wrong file and every
566
+ * attachment then resolves against the wrong pixels — silently, which is the
567
+ * exact failure mode this tool exists to remove.
568
+ */
569
+ export function resolveArtifacts(target: string, atlasFlag: string | undefined): { skeletonPath: string; atlasPath: string } {
570
+ const abs = resolve(target);
571
+ if (!existsSync(abs)) throw new UsageError(`nothing at ${abs}`);
572
+ if (statSync(abs).isDirectory()) {
573
+ return {
574
+ skeletonPath: join(abs, 'skeleton.json'),
575
+ atlasPath: atlasFlag ? resolve(atlasFlag) : join(abs, 'skeleton.atlas'),
576
+ };
577
+ }
578
+ if (!abs.endsWith('.json')) throw new UsageError(`${abs} is neither a directory nor a .json skeleton`);
579
+ if (atlasFlag) return { skeletonPath: abs, atlasPath: resolve(atlasFlag) };
580
+ const dir = dirname(abs);
581
+ const atlases = readdirSync(dir)
582
+ .filter((f) => f.endsWith('.atlas'))
583
+ .sort();
584
+ if (atlases.length === 1) return { skeletonPath: abs, atlasPath: join(dir, atlases[0]) };
585
+ if (atlases.length === 0) throw new UsageError(`no .atlas beside ${abs}; name one with --atlas <path>`);
586
+ throw new UsageError(
587
+ `${atlases.length} atlases beside ${abs} (${atlases.join(', ')}); name the right one with --atlas <path> ` +
588
+ '— guessing by filename is how an attachment quietly resolves against the wrong page',
589
+ );
590
+ }
591
+
592
+ /**
593
+ * check — how close does the candidate LOOK to the reference frames?
594
+ *
595
+ * ⭐ The gate cannot see a wrong animation. It parses, it steps, it refuses the
596
+ * degenerate — and a rig whose easings are all reversed passes it green, which is
597
+ * not a hypothetical: ladder rung 1's first honest run shipped exactly that build
598
+ * and the validator was structurally incapable of noticing. `diff` cannot see it
599
+ * either, because it compares structure and a reversed curve is the same curve
600
+ * count. The only thing that can is a picture, so this renders the candidate into
601
+ * the reference's own frame and compares pixels.
602
+ *
603
+ * 🔒 It never reads the reference skeleton — see `src/check.ts`. That is what
604
+ * keeps it usable **inside** an authoring loop rather than at the finish line the
605
+ * way `bench` is: an author may run it as often as they like without their run
606
+ * stopping being an authoring run.
607
+ *
608
+ * There is no pass mark, for the same reason `diff` has none.
609
+ */
610
+ function readCheckFlags(
611
+ flags: Record<string, string>,
612
+ ): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom' | 'skin'> {
613
+ const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom' | 'skin'> = {};
614
+ if (flags.framing !== undefined) {
615
+ if (flags.framing !== 'per-shot' && flags.framing !== 'shared') {
616
+ throw new UsageError('--framing takes per-shot (the default) or shared');
617
+ }
618
+ out.framing = flags.framing;
619
+ }
620
+ if (flags.fps !== undefined) {
621
+ const fps = Number(flags.fps);
622
+ if (!Number.isFinite(fps) || fps <= 0) throw new UsageError('--fps must be a positive number');
623
+ out.fps = fps;
624
+ }
625
+ if (flags.viewport !== undefined) {
626
+ const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
627
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
628
+ throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
629
+ }
630
+ if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
631
+ out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
632
+ }
633
+ if (flags.as !== undefined) out.as = flags.as;
634
+ // The name is not checked against the candidate here: `check` owns that
635
+ // refusal, because it is the side that has the skeleton open and can list the
636
+ // skins it declares. `render` checks its own for the same reason.
637
+ if (flags.skin !== undefined) out.skin = flags.skin;
638
+ if (flags['texture-from'] !== undefined) {
639
+ const path = resolve(flags['texture-from']);
640
+ if (!existsSync(path)) {
641
+ throw new UsageError(
642
+ `--texture-from ${path} is not a file. It takes the ATLAS the reference frames were rendered through — the ` +
643
+ "example's own .atlas — so check can measure how much of the MAE is texture resampling.",
644
+ );
645
+ }
646
+ out.textureFrom = { atlasText: readFileSync(path, 'utf8'), atlasDir: dirname(path), label: flags['texture-from'] };
647
+ }
648
+ return out;
649
+ }
650
+
651
+ export function runCheck(
652
+ candidate: string,
653
+ atlasFlag: string | undefined,
654
+ framesDir: string,
655
+ flags: Record<string, string>,
656
+ plates?: CheckPlates,
657
+ ): CheckReport {
658
+ const { skeletonPath, atlasPath, atlasText } = resolveDrawable(candidate, atlasFlag);
659
+ // The poser is `render`'s choice, over the same two files (issue #968): the
660
+ // paths are what `candidatePosers` looks beside for `skeleton.model.json`.
661
+ const poser = readPoserFlag(flags);
662
+ return checkAgainstFrames({
663
+ skeletonText: readSkeletonText(skeletonPath),
664
+ atlasText,
665
+ atlasDir: dirname(atlasPath),
666
+ framesDir,
667
+ labels: { skeleton: skeletonPath, atlas: atlasText === null ? `${atlasPath} — not there (${ATLAS_ABSENT})` : atlasPath },
668
+ candidatePaths: { skeleton: skeletonPath, atlas: atlasPath },
669
+ ...(poser === undefined ? {} : { poser }),
670
+ ...readCheckFlags(flags),
671
+ ...(plates === undefined ? {} : { plates }),
672
+ });
673
+ }
674
+
675
+ export function writeJson(target: string, body: unknown): void {
676
+ const out = resolve(target);
677
+ mkdirSync(dirname(out), { recursive: true });
678
+ writeFileSync(out, `${JSON.stringify(body, null, 2)}\n`);
679
+ console.log(`rigc: wrote ${out}`);
680
+ }
681
+
682
+ // ---------------------------------------------------------------------------
683
+ // seeing the result — render and preview
684
+ // ---------------------------------------------------------------------------
685
+ //
686
+ // ⭐ Why two commands exist for one question. `validate` says the artifact is
687
+ // valid, `check` says how close it is to reference frames — and a first user has
688
+ // neither a reference nor any way to look at what they built. A rig whose head
689
+ // sits visibly off its torso passes the gate, loads in `spine-core` and steps
690
+ // cleanly, because the offsets are the ones the spec asked for. The only remedy
691
+ // is looking (issue #216).
692
+ //
693
+ // `render` looks with OUR rasteriser: PNGs on disk, no browser, no network, and
694
+ // the same frame geometry `check` compares against — so its output is a frame set
695
+ // like any other, sidecar included. `preview` looks with ESOTERIC'S, in one HTML
696
+ // file, which is the stronger statement of the two: a rig that plays there has
697
+ // been played by the reference implementation rather than by ours (issue #151).
698
+ //
699
+ // Both take a COMPILED artifact rather than a rig and motion spec. That is what
700
+ // `check`, `bench` and `validate` all take, it is what `build --out` leaves
701
+ // behind, and it keeps `--out` meaning one thing per command instead of naming
702
+ // the build directory on the way in and the pictures on the way out.
703
+
704
+ /** Both commands' shared front door: which artifact, and what is in it. */
705
+ export function resolveViewable(flags: Record<string, string>): {
706
+ skeletonPath: string;
707
+ atlasPath: string;
708
+ atlasDir: string;
709
+ } {
710
+ if (flags.candidate === undefined) {
711
+ throw new UsageError('needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
712
+ }
713
+ const { skeletonPath, atlasPath } = resolveArtifacts(flags.candidate, flags.atlas);
714
+ for (const path of [skeletonPath, atlasPath]) {
715
+ if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
716
+ }
717
+ return { skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
718
+ }
719
+
720
+ /** What `render` and `check` say where a rigc build's atlas is not there (issue #1020). */
721
+ export const ATLAS_ABSENT = 'a build the core poses from a rigc-compiled/2 or /3 document needs none';
722
+
723
+ /**
724
+ * The candidate `render` and `check` draw, with its atlas text — or `null`
725
+ * where the atlas file is not there and need not be (issue #1020).
726
+ *
727
+ * ⭐ A rigc build is drawn by the core from its `skeleton.model.json`, and a
728
+ * `rigc-compiled/2` document states where each region sits on its page, so
729
+ * the build's `skeleton.atlas` is not needed to draw it. Its absence is let
730
+ * through here only where it can be that case — no `--atlas` named, a model
731
+ * document beside the skeleton, and no atlas beside it at all — and the poser
732
+ * choice decides the rest: anything that is read through an atlas after all
733
+ * (a `rigc-compiled/1` document, a build the core refuses) is refused there
734
+ * naming the file and why (`CandidateAtlasError`). An atlas that IS there is
735
+ * read, as it always was: the core holds it to the document's `pages` and
736
+ * draws through spine-core, saying why, when it is not the one the build
737
+ * wrote (#1016), and `check` reads its `scale:` lines for the texture note.
738
+ * Everywhere else this is `resolveViewable`'s refusal, word for word.
739
+ */
740
+ export function resolveDrawable(target: string, atlasFlag: string | undefined): { skeletonPath: string; atlasPath: string; atlasText: string | null } {
741
+ const abs = resolve(target);
742
+ if (atlasFlag === undefined && existsSync(abs)) {
743
+ const directory = statSync(abs).isDirectory();
744
+ const skeletonPath = directory ? join(abs, 'skeleton.json') : abs;
745
+ const dir = dirname(skeletonPath);
746
+ const atlasPath = join(dir, 'skeleton.atlas');
747
+ const noAtlas = directory ? !existsSync(atlasPath) : abs.endsWith('.json') && !readdirSync(dir).some((f) => f.endsWith('.atlas'));
748
+ if (noAtlas && existsSync(join(dir, MODEL_DOCUMENT_FILE))) {
749
+ if (!existsSync(skeletonPath)) throw new UsageError(`nothing at ${skeletonPath}`);
750
+ return { skeletonPath, atlasPath, atlasText: null };
751
+ }
752
+ }
753
+ const { skeletonPath, atlasPath } = resolveViewable({ candidate: target, ...(atlasFlag === undefined ? {} : { atlas: atlasFlag }) });
754
+ return { skeletonPath, atlasPath, atlasText: readFileSync(atlasPath, 'utf8') };
755
+ }
756
+
757
+ /** `--animation`, checked against what the skeleton actually carries. */
758
+ export function readAnimationFlag(flags: Record<string, string>, available: string[]): string | undefined {
759
+ const name = flags.animation;
760
+ if (name === undefined) return undefined;
761
+ if (!available.includes(name)) {
762
+ throw new UsageError(
763
+ `no animation ${JSON.stringify(name)} in this skeleton; it has [${available.join(', ') || 'none'}]`,
764
+ );
765
+ }
766
+ return name;
767
+ }
768
+
769
+ /**
770
+ * `--poser` as spelled, checked against the posers there are — shared by
771
+ * `render` and `check`. On `render` it is read where its refusal has always
772
+ * stood, after the candidate's own flags; the candidate is loaded before that
773
+ * with the spelling as given (`posersAsked`), so a rigc build the core poses
774
+ * is chosen without loading spine-core (issue #1014).
775
+ */
776
+ export function readPoserFlag(flags: Record<string, string>): PoserName | undefined {
777
+ const raw = flags.poser;
778
+ const forced = POSER_NAMES.find((name) => name === raw);
779
+ if (raw !== undefined && forced === undefined) {
780
+ throw new UsageError(`--poser ${JSON.stringify(raw)}: known posers are ${POSER_NAMES.join(', ')}`);
781
+ }
782
+ return forced;
783
+ }
784
+
785
+ /**
786
+ * What `render` and `preview` say of their framing on a skeleton that declares
787
+ * no stage (issue #714).
788
+ *
789
+ * ⚠️ **Neither frames to a stage on ANY skeleton**, so this is not a fallback
790
+ * being announced: `framingViewport` is the union of every animation's posed
791
+ * bounds, and the Spine Web Player's `calculateAnimationViewport` samples the
792
+ * playing animation's bounds whenever its config states no viewport box, which
793
+ * `buildPreview` never does. The line is printed only where a stage is absent
794
+ * because that is the one case where a reader can ask *what box stood in for
795
+ * it* — and the answer has to be "none", said, rather than a rectangle that looks
796
+ * like a default. On a staged skeleton the output is the bytes it always was.
797
+ */
798
+ export const STAGELESS_FRAMING = {
799
+ render:
800
+ 'framing the posed extent of every animation, padded — this skeleton declares no stage, and nothing stands ' +
801
+ 'in for one: render frames to the posed extent whether or not a stage is declared',
802
+ preview:
803
+ "framing the Spine Web Player's own: the posed extent of the animation it plays — this skeleton declares no " +
804
+ 'stage, and nothing stands in for one: the player frames that way whether or not a stage is declared',
805
+ } as const;
806
+
807
+ // ---------------------------------------------------------------------------
808
+ // reading a given condition — pose
809
+ // ---------------------------------------------------------------------------
810
+ //
811
+ // ⭐ Every other command here takes a spec and looks at what came out. This one
812
+ // runs the other way: it takes a PICTURE the user already has — a key pose — and
813
+ // reads spec coordinates out of it, so an agent can state those poses in a rig and
814
+ // a motion by construction and spend its loops on the part nobody can measure, the
815
+ // movement between them.
816
+ //
817
+ // 🚫 It grades nothing, and the distinction is load-bearing rather than modest.
818
+ // `check` and `bench` compare a build against a reference and their numbers mean
819
+ // "how close"; a pose frame is not a reference, it is an INPUT, and once the spec
820
+ // states it there is nothing left to be close to. So the residual here is a trust
821
+ // signal — how much of the frame this placement actually explains — and the only
822
+ // threshold in `src/pose.ts` is the one that decides whether to print an answer at
823
+ // all, which the caller can move.
824
+ //
825
+ // rigc pose --images parts/ --frame poseA.png [--out pose.json]
826
+
827
+ export const DEFAULT_POSE_OUT = 'pose.json';
828
+
829
+ // ---------------------------------------------------------------------------
830
+ // reading the half a picture hides — chainfit
831
+ // ---------------------------------------------------------------------------
832
+ //
833
+ // ⭐ `pose` above reads a picture with nothing but the loose parts, and refuses
834
+ // the parts another part is drawn over — a residual measured through an occluder
835
+ // rises AT the correct placement, so the honest answer is a refusal. This reads
836
+ // those, and the whole difference is that it is also given the CANDIDATE: with a
837
+ // draw order the covered pixels can be excluded from a part's objective instead
838
+ // of charged to it, and with a hierarchy a child of a placed bone has one degree
839
+ // of freedom — the hinge about its own pivot — where `pose` has four.
840
+ //
841
+ // 🚫 Same phase and the same framing as `pose`: it reads a given condition into
842
+ // spec coordinates and grades nothing. Every residual is a trust signal, every
843
+ // threshold is reported, and `visibleShare` is how much of the part the number
844
+ // was even computed on.
845
+ //
846
+ // rigc chainfit --candidate <dir> --images <dir> --frame poseA.png [--anchor pose.json]
847
+
848
+ export const DEFAULT_CHAINFIT_OUT = 'chainfit.json';
849
+
850
+ // ---------------------------------------------------------------------------
851
+ // choosing between results — vote
852
+ // ---------------------------------------------------------------------------
853
+ //
854
+ // ⭐ `preview` shows candidates and asks nothing; this shows two to four of them
855
+ // side by side, hides where each came from, 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
+ export const DEFAULT_BALLOT = 'ballot.html';
868
+ export const DEFAULT_LEDGER = 'votes.jsonl';
869
+
870
+ // ---------------------------------------------------------------------------
871
+ // skills install — put the shipped skills where an agent host looks (issue #831)
872
+ // ---------------------------------------------------------------------------
873
+ //
874
+ // After `bun add -d spine-rigc` the skills sit at `node_modules/spine-rigc/skills/`,
875
+ // which no host reads. Codex, Gemini CLI and Antigravity all read
876
+ // `<workspace>/.agents/skills/<name>/`, so this links every `skills/<name>/` the
877
+ // package ships into one directory — `.agents/skills` under the working
878
+ // directory unless `--dir` says otherwise.
879
+ //
880
+ // ⭐ The skills are found from THIS FILE's location, never from the working
881
+ // directory: the command installs the package it is, and a cwd that happens to
882
+ // hold some other `skills/` is not a source. That is also why it lives here and
883
+ // not in `src/`: its one input is where the CLI was installed, nothing else
884
+ // calls it, and `src/` is about rigs.
885
+ //
886
+ // A RELATIVE symlink by default, so the directory survives the project being
887
+ // moved or cloned elsewhere and an upgrade of the package is seen with no second
888
+ // run. `--copy` writes the folder instead, for a host that does not follow a
889
+ // linked skill folder.
890
+ //
891
+ // 🔒 **An entry that is already there and is not what this command would write is
892
+ // refused by name, and then nothing at all is written.** The check runs over
893
+ // every skill before the first write, so a refusal never leaves half an install
894
+ // behind. The one entry that is NOT refused is the one this command would have
895
+ // made — a link that already resolves to the same skill folder, however it is
896
+ // spelled, or with `--copy` a folder whose files are byte for byte the package's
897
+ // — and a run over only those says it had nothing to do. No lifecycle script
898
+ // does this on install: a postinstall writing into a consumer's project root is
899
+ // refused as design, and Bun does not run a dependency's lifecycle scripts
900
+ // outside `trustedDependencies`, so half the installs would silently skip it.
901
+ // ---------------------------------------------------------------------------
902
+
903
+ /** The default `--dir`, resolved against the caller's working directory. */
904
+ export const DEFAULT_SKILLS_DIR = '.agents/skills';
905
+
906
+ /** An install refused before its first write — nothing to install, or an entry in the way. Exit 1. */
907
+ export class SkillsInstallError extends Error {}
908
+
909
+ // ---------------------------------------------------------------------------
910
+ // usage / per-command help
911
+ // ---------------------------------------------------------------------------
912
+
913
+ /**
914
+ * One meaning per flag name, shared by every command that takes it — the
915
+ * single place this project states what a flag means. AUTHORING.md §0 quotes
916
+ * this table for `build`'s `--rig`/`--motion`/`--out`/`--images`/`--manifest`/
917
+ * `--profile`; if the two ever disagree, this is the one the code runs.
918
+ */
919
+ const FLAG_MEANINGS: Record<string, string> = {
920
+ rig: 'the rig spec — skeleton structure',
921
+ motion: 'the motion spec — time',
922
+ out: 'directory for skeleton.json + skeleton.atlas (and, from build, skeleton.model.json); atlas page paths and skeleton.images are written relative to it',
923
+ images: "override the rig spec's own images directory (relative to your working directory)",
924
+ manifest: 'a cut manifest, for a rig with measured art behind it; a foreign skeleton has none',
925
+ 'copy-images':
926
+ 'also copy every referenced page PNG into --out and rewrite the atlas to the copies, so the directory is ' +
927
+ 'self-contained enough to zip or commit on its own, and point skeleton.images at --out itself so the editor finds ' +
928
+ 'the parts beside the skeleton on import (default: page paths still point at the source art)',
929
+ pack: 'arrange every part PNG onto shared atlas page(s) written into --out as real PNGs, instead of one page ' +
930
+ 'per part. Lossless: every region is a byte-for-byte copy and nothing is resampled, trimmed or rotated ' +
931
+ '(default: one part, one page, pointing at the source art)',
932
+ 'page-size': `largest page edge, --pack only (default ${DEFAULT_PAGE_SIZE}); pages are powers of two and the ` +
933
+ 'one written is the smallest that holds the pack, spilling to more pages only when the set will not fit',
934
+ padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
935
+ "extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
936
+ 'page-edges': `what a page's edges may be, --pack only (default ${DEFAULT_PAGE_EDGES}): pot is a power of two on ` +
937
+ 'both; free tries every width from the widest part up, takes the height the placement needs and keeps ' +
938
+ 'the least area — a smaller page, at the cost of region attachments sampling within 1 LSB of the loose ' +
939
+ 'build rather than exactly',
940
+ 'atlas-in':
941
+ 'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
942
+ 'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
943
+ cut: 'look up a named cut in --cuts <cuts.json>, instead of --rig/--motion/--out',
944
+ cuts: 'the cuts.json --cut names',
945
+ profile:
946
+ 'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
947
+ "correctly; spine-html = also this project's renderer/archetype policy",
948
+ atlas: "the candidate's atlas, when it is not beside the skeleton",
949
+ reference: 'the reference skeleton to pose beside the candidate — a directory or a skeleton.json path',
950
+ 'reference-atlas': "the reference's atlas, when it is not beside the reference skeleton",
951
+ bones: `a bone correspondence — { "spec": "${BONEDIST_SPEC}", "bones": { "<candidate bone>": "<reference bone>" }, ` +
952
+ '"animations"?: { … } } — or `identity` to state that the two skeletons use the same names. An INPUT, never ' +
953
+ 'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
954
+ 'as a measurement',
955
+ 'all-bones': 'print every bone pair, not just the worst by position',
956
+ geometry:
957
+ `also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
958
+ "slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
959
+ 'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
960
+ poser:
961
+ "`render` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
962
+ 'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
963
+ "beside the skeleton and the skeleton is the one the document records (spine.sha256), `spine` otherwise and wherever the core refuses the input by name; the `poser` " +
964
+ 'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name. ' +
965
+ "`explain`: which reads and poses the DEFORM block's survey — `core` (the model document the compile writes, spine-core untouched) or " +
966
+ '`spine` (the Spine skeleton parsed and posed by spine-core); default `core`, and `spine` where the core refuses the document, which the block names',
967
+ 'texture-from':
968
+ "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
969
+ 'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
970
+ 'through. ⚠️ NOT --atlas: that one names the candidate\'s own atlas and loading a foreign one there re-seats ' +
971
+ 'every region attachment on its packing, so a rotated or trimmed pack moves the geometry too',
972
+ candidate: 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path',
973
+ frames: 'a rendered reference frame set (a skeleton root, or one animation directory)',
974
+ fps: 'frame rate, only for a frame set with no frames.json sidecar',
975
+ viewport: "pin the candidate's world box, y up, instead of fitting it",
976
+ framing: 'fit each frame set on its own (default) or once across all of them',
977
+ as: 'the candidate animation to play, when it is named differently from the frame set',
978
+ 'all-frames': 'print every frame, not just the worst by MAE',
979
+ json: 'also write the whole report to this path',
980
+ frame: 'one pose frame — a picture of the pose to read the part placements out of',
981
+ scale: `the scale window to search, as frame pixels per part pixel (default \`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`)`,
982
+ rotation: 'the rotation window to search, in screen degrees (default `-180,180`, a full turn)',
983
+ 'max-residual':
984
+ `above this residual a placement is refused by name instead of reported flat (default ${DEFAULT_MAX_RESIDUAL}); ` +
985
+ 'it is a reporting threshold, not a pass bar',
986
+ anchor:
987
+ 'a `rigc pose` report for THIS frame, whose confident placements become the anchors the chains hang off ' +
988
+ '(default: run that pass internally over exactly the parts the candidate draws)',
989
+ hinge:
990
+ `the window each child bone's local rotation is searched over, in Spine degrees about its setup value ` +
991
+ `(default \`${DEFAULT_HINGE_MIN},${DEFAULT_HINGE_MAX}\`, a full turn — one degree of freedom is cheap enough not ` +
992
+ 'to risk a window that does not contain the truth)',
993
+ stretch:
994
+ 'also search a uniform scale on every bone, over this ratio either way (e.g. 1.25). Without it the stretch ' +
995
+ "degree of freedom is searched only where the candidate's own animations key a `scale` timeline, because a rig " +
996
+ 'that never scales a bone is a rig saying that bone does not stretch',
997
+ 'min-visible':
998
+ `below this share of a part surviving the parts drawn over it, the placement is refused by name instead of ` +
999
+ `reported flat (default ${DEFAULT_MIN_VISIBLE}); the best one found is still printed, and it is a reporting ` +
1000
+ 'threshold, not a pass bar',
1001
+ passes:
1002
+ `how many times the occluder masks are rebuilt from the answers and the fit rerun (default ${DEFAULT_PASSES}); ` +
1003
+ "pass 1 freezes each part's visible set where the RIG predicts it, later passes where the last one landed",
1004
+ 'anchor-residual':
1005
+ `the residual a \`pose\` placement must be within to anchor a chain (default ${ANCHOR_MAX_RESIDUAL}, with ` +
1006
+ `unexplained ≤ ${ANCHOR_MAX_UNEXPLAINED} and unambiguous — the 2026-09-03 measurement run's own clean-frame criterion)`,
1007
+ 'inward-lever':
1008
+ `how far apart, in frame pixels, two anchored descendants have to sit before the rotation they determine is ` +
1009
+ `printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
1010
+ 'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
1011
+ animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
1012
+ skin:
1013
+ 'pose under this skin, by the name the skeleton declares. Without it NO skin is set — every slot resolves ' +
1014
+ 'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
1015
+ 'skeleton does not declare is refused with the ones it does. `render` records the skin in frames.json and ' +
1016
+ '`check` reads it back, so a skin-A candidate is not scored against skin-B frames in silence',
1017
+ slot:
1018
+ 'draw only these slots, comma-separated, in the skeleton\'s draw order, on the SAME grid as the whole rig: the ' +
1019
+ 'viewport is still fitted to every slot, so this frame overlays the full one pixel for pixel. A name the ' +
1020
+ 'skeleton does not declare is refused with every one it does; a slot whose art lives only under another skin ' +
1021
+ 'is refused naming that skin. frames.json records the subset, and `check` refuses such a set as a reference',
1022
+ hide:
1023
+ 'draw every slot but these, comma-separated — `--slot` the other way round, on the same grid, recorded and ' +
1024
+ 'refused the same way. Not with `--slot`: the two are one statement',
1025
+ max: 'longest side of a rendered frame, in pixels (default 256)',
1026
+ record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
1027
+ ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
1028
+ ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
1029
+ again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
1030
+ dir:
1031
+ `the directory to install into, resolved against your working directory (default \`${DEFAULT_SKILLS_DIR}\`, the ` +
1032
+ 'workspace directory Codex, Gemini CLI and Antigravity read skills from)',
1033
+ copy:
1034
+ 'copy each skill folder instead of linking it, for a host that does not follow a linked skill folder. A copy ' +
1035
+ 'is not reached by an upgrade of the package, and one that is no longer the package\'s bytes is refused by name ' +
1036
+ 'on the next run — remove it and run again (default: a relative symlink, which an upgrade reaches with no ' +
1037
+ 'second run)',
1038
+ name: "the rig spec's own name, which the motion spec's archetype must match (default: the skeleton file's basename)",
1039
+ art: 'how the written spec reaches the art, which a skeleton does not encode: `loose` names an image per ' +
1040
+ "attachment, measured out of the rig spec's own images directory (--images writes it; without it, `build " +
1041
+ '--images <dir>` on every rebuild), `none` states width/height only for `build --atlas-in <pack>` to ' +
1042
+ 'resolve (default: loose)',
1043
+ stage:
1044
+ 'the setup bounding box — `skeleton.x,y,width,height` — to ADD to a skeleton that declares none. It cannot be ' +
1045
+ 'derived: posing the rig gives the ANIMATED extent, which is a different number from the setup box, so this ' +
1046
+ "is the caller's value, and without it the absence is carried: the spec states `\"width\": null, \"height\": " +
1047
+ 'null` and the rebuild declares no stage either. ⚠️ An editor export MAY ' +
1048
+ 'carry none; every editor export measured for this project carries one and ingest reads it straight through, ' +
1049
+ 'so the flag is for a file that really has none rather than for editor exports as a class. ⛔ Beside a ' +
1050
+ 'skeleton that already declares a box it is REFUSED rather than ignored: two sources for one value, and the ' +
1051
+ 'file is the record of what was measured',
1052
+ help: "show this command's flags and exit",
1053
+ };
1054
+
1055
+ /** The `<value>` a flag takes, for its column in a command's flag table. Absent for a boolean switch. */
1056
+ const FLAG_VALUES: Record<string, string> = {
1057
+ rig: '<path>',
1058
+ motion: '<path>',
1059
+ out: '<dir>',
1060
+ images: '<dir>',
1061
+ manifest: '<path>',
1062
+ cut: '<name>',
1063
+ cuts: '<path>',
1064
+ profile: 'spine|spine-html',
1065
+ atlas: '<path>',
1066
+ 'atlas-in': '<file.atlas>',
1067
+ 'page-size': '<px>',
1068
+ padding: '<px>',
1069
+ 'page-edges': 'pot|free',
1070
+ 'texture-from': '<path>',
1071
+ poser: 'core|spine',
1072
+ reference: '<dir|skeleton.json>',
1073
+ 'reference-atlas': '<path>',
1074
+ bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
1075
+ candidate: '<dir|skeleton.json>',
1076
+ frames: '<dir>',
1077
+ fps: '<n>',
1078
+ viewport: '<x,y,w,h>',
1079
+ framing: 'per-shot|shared',
1080
+ as: '<name>',
1081
+ json: '<out>',
1082
+ frame: '<path>',
1083
+ scale: '<min,max>',
1084
+ rotation: '<min,max>',
1085
+ 'max-residual': '<0..1>',
1086
+ anchor: '<pose.json>',
1087
+ hinge: '<min,max>',
1088
+ stretch: '<ratio>',
1089
+ 'min-visible': '<0..1>',
1090
+ passes: '<n>',
1091
+ 'anchor-residual': '<0..1>',
1092
+ 'inward-lever': '<px>',
1093
+ animation: '<name>',
1094
+ skin: '<name>',
1095
+ slot: '<name[,name…]>',
1096
+ hide: '<name[,name…]>',
1097
+ max: '<px>',
1098
+ record: '<result.json>',
1099
+ ballot: '<ballot.html>',
1100
+ ledger: '<votes.jsonl>',
1101
+ name: '<n>',
1102
+ art: 'loose|none',
1103
+ stage: '<x,y,w,h>',
1104
+ dir: '<path>',
1105
+ };
1106
+
1107
+ interface CommandDoc {
1108
+ name: string;
1109
+ /** One or more invocation forms, each already spelling the command name. */
1110
+ usage: string[];
1111
+ /** Flag names (into FLAG_MEANINGS/FLAG_VALUES), in display order. `--help` is appended automatically. */
1112
+ flags: string[];
1113
+ /**
1114
+ * Per-command wording for a flag whose value or meaning genuinely differs here.
1115
+ *
1116
+ * ⚠️ The default above it — one meaning per flag name, everywhere — is the rule
1117
+ * and this is the named exception to it, not a second table. What earns an entry
1118
+ * is the criterion rather than a headcount: the flag is **shared with another
1119
+ * command**, and it means something different in this one. Examples, and not an
1120
+ * inventory: `--out` is a directory of artifacts to `build`, a directory of specs
1121
+ * to `ingest`, a directory of pictures to
1122
+ * `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
1123
+ * was RECORDED at to `check`, which reads it off a sidecar, and the rate to
1124
+ * SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
1125
+ * everywhere except `vote`, which is the one command that takes several and is
1126
+ * the reason there is a ballot at all. Writing any of them as one sentence
1127
+ * covering every command would leave every command's own help less true.
1128
+ *
1129
+ * ⛔ The other side of the criterion, which is the one that keeps this from
1130
+ * becoming the second table it says it is not: a flag no other command takes has
1131
+ * nothing to differ FROM, so its wording belongs in `FLAG_MEANINGS` /
1132
+ * `FLAG_VALUES` above and an entry here for it buys only a second place to look.
1133
+ * Both halves are read off `--help` by `CLI71` in `selftest.ts`, which is why
1134
+ * this sentence no longer counts anything: it said *"three"* where #605 counted
1135
+ * nine, and nothing had ever compared the two.
1136
+ */
1137
+ overrides?: Record<string, { value?: string; meaning?: string }>;
1138
+ /**
1139
+ * Lines printed under the flag table: what this command's own figures mean.
1140
+ *
1141
+ * ⚠️ Not a second place to describe a flag. It exists for what is true of the
1142
+ * **command** and of no flag it takes — and the case that earned it is issue
1143
+ * #678: `pose` and `chainfit` each say *"it is a reporting threshold, not a
1144
+ * pass bar"* on the flag that carries their threshold, and `check` has no such
1145
+ * flag, so its page said nothing at all about whether any of its figures is a
1146
+ * bar to beat. An agent reading `slot drift worst 3.7 px` off a correct rig had
1147
+ * no page to consult and no exit code to read it in.
1148
+ */
1149
+ notes?: string[];
1150
+ /**
1151
+ * What the command's body reaches of spine-core, as data (issue #1052):
1152
+ * `false` when nothing — it runs in an entry that links none of the runtime
1153
+ * (`cli_core.ts`) — and otherwise what it runs through the runtime for,
1154
+ * which is what that entry says when it refuses the command. ⚠️ Not a
1155
+ * claim anybody keeps by hand: `RC26` in `selftest.ts` derives it from the
1156
+ * import graph of the module whose bodies register the command, and a
1157
+ * mark that disagrees with the graph is red by name.
1158
+ */
1159
+ runtime: false | { for: string };
1160
+ /**
1161
+ * Whether the command exists for the Spine format — reads, writes, compares
1162
+ * or embeds Spine skeleton data as the whole of what it does with it — so
1163
+ * the commands that are Spine's without being the runtime's (`ingest`,
1164
+ * `diff`) can be moved to another side in one place.
1165
+ */
1166
+ spineFormat: boolean;
1167
+ }
1168
+
1169
+ export const COMMANDS: CommandDoc[] = [
1170
+ {
1171
+ name: 'build',
1172
+ runtime: { for: 'the gate round-trips every build through it before anything is written' },
1173
+ spineFormat: true,
1174
+ usage: [
1175
+ 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
1176
+ `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] (parts onto shared pages, written into --out)`,
1177
+ 'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
1178
+ 'rigc build --cut <name> --cuts <cuts.json>',
1179
+ ],
1180
+ flags: [
1181
+ 'rig',
1182
+ 'motion',
1183
+ 'out',
1184
+ 'manifest',
1185
+ 'images',
1186
+ 'copy-images',
1187
+ 'pack',
1188
+ 'page-size',
1189
+ 'padding',
1190
+ 'page-edges',
1191
+ 'atlas-in',
1192
+ 'cut',
1193
+ 'cuts',
1194
+ 'profile',
1195
+ ],
1196
+ },
1197
+ {
1198
+ name: 'explain',
1199
+ runtime: false,
1200
+ spineFormat: false,
1201
+ usage: [
1202
+ 'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--poser core|spine] (it never gates, and writes nothing)',
1203
+ 'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
1204
+ 'rigc explain --cut <name> --cuts <cuts.json>',
1205
+ ],
1206
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser'],
1207
+ notes: [
1208
+ 'this line said "the same arguments as build, minus --profile" and was false in both',
1209
+ 'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
1210
+ 'command read what `ingest --art none` writes was reachable and undocumented, while',
1211
+ '--pack, --page-size, --padding, --page-edges and --copy-images are build\'s and do nothing here —',
1212
+ 'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
1213
+ 'above, and that is now the whole of it.',
1214
+ ],
1215
+ },
1216
+ {
1217
+ name: 'validate',
1218
+ runtime: { for: 'the gate it re-runs is the round trip through it' },
1219
+ spineFormat: true,
1220
+ usage: [
1221
+ 'rigc validate <dir | skeleton.json> [--atlas <path>] [--profile spine|spine-html]',
1222
+ 'rigc validate --cut <name> --cuts <cuts.json> (also re-derives declared durations)',
1223
+ ],
1224
+ flags: ['atlas', 'profile', 'cut', 'cuts', 'rig', 'motion', 'out', 'manifest', 'images'],
1225
+ },
1226
+ {
1227
+ name: 'ingest',
1228
+ runtime: false,
1229
+ spineFormat: true,
1230
+ usage: ['rigc ingest <skeleton.json> --out <dir> [--name <n>] [--art loose|none] [--images <dir>] [--stage x,y,w,h]'],
1231
+ flags: ['out', 'name', 'art', 'images', 'stage'],
1232
+ overrides: {
1233
+ out: {
1234
+ value: '<dir>',
1235
+ meaning: 'directory to write rig.json, motion.json and findings.json into — the two specs that rebuild this skeleton',
1236
+ },
1237
+ images: {
1238
+ value: '<dir>',
1239
+ meaning:
1240
+ "WRITE the rig spec's own images directory, spelled relative to --out, so the rebuild is a plain `build " +
1241
+ '--rig … --motion … --out …` with no flag. ⚠️ The opposite direction from `build --images`, which ' +
1242
+ 'OVERRIDES that field: this one fills it in. Without it the field is left out and every `image` resolves ' +
1243
+ 'against --out itself. Refused together with --art none, which writes no `image` for it to be the base of',
1244
+ },
1245
+ },
1246
+ },
1247
+ {
1248
+ name: 'diff',
1249
+ runtime: false,
1250
+ spineFormat: true,
1251
+ usage: ['rigc diff <candidate.json> <reference.json> [--as <candidate>=<reference>]… [--json <out>]'],
1252
+ flags: ['as', 'json'],
1253
+ overrides: {
1254
+ as: {
1255
+ value: '<candidate>=<reference>',
1256
+ meaning:
1257
+ 'pair a candidate animation with a reference one, so the name-agnostic `animations` block can be ' +
1258
+ 'measured over shots the two files call different things. Repeatable, one pair each. An INPUT and never ' +
1259
+ 'derived: two skeletons cannot say which of their shots are the same shot. Without it the block appears ' +
1260
+ 'only when each side has exactly one animation, which pairs by position, and is otherwise absent rather ' +
1261
+ 'than guessed',
1262
+ },
1263
+ },
1264
+ notes: [
1265
+ 'the `animations` block reads two figures once something has paired the shots, exactly as',
1266
+ '`bones` and `slots` do: name-matched, where `names` lives, and name-agnostic over the pair.',
1267
+ 'A candidate that followed a brief withholding the animation name reads `count` 1/1 and 0.000',
1268
+ 'on every other name-matched measure — including `duration` and `key_counts` it may have got',
1269
+ 'exactly right — so read the pair and not the section mean.',
1270
+ ],
1271
+ },
1272
+ {
1273
+ name: 'check',
1274
+ runtime: false,
1275
+ spineFormat: false,
1276
+ usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
1277
+ flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'poser', 'all-frames', 'json', 'out'],
1278
+ overrides: {
1279
+ skin: {
1280
+ meaning:
1281
+ 'pose the CANDIDATE under this skin, by the name it declares. Without it no skin is set and the ' +
1282
+ 'default skin alone is compared, which for a multi-skin rig is a comparison that can see none of the ' +
1283
+ 'contested art. The frames are checked back: a set whose frames.json records a different skin is ' +
1284
+ 'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
1285
+ },
1286
+ out: {
1287
+ value: '<dir>',
1288
+ meaning:
1289
+ 'also write the PICTURE each listed frame\'s figures came from, as <dir>/<set>/f####.png: reference, ' +
1290
+ 'candidate, difference and overlay side by side at the comparison grid\'s native size, with the table\'s ' +
1291
+ 'figures burned in and one row per slot under them, beside a frames.json that says what they are pictures ' +
1292
+ 'of. The frames are the ones the table lists, so --all-frames writes every compared one. Each <dir>/<set>/ ' +
1293
+ 'is cleared first; a file at <dir>, or a directory that is or holds --frames, is refused. See ' +
1294
+ 'docs/AUTHORING.md §9.2.1',
1295
+ },
1296
+ },
1297
+ notes: [
1298
+ 'every figure here is a reporting threshold, not a pass bar. Nothing in this report',
1299
+ 'grades, no number has to beat anything, and the exit code says only whether the',
1300
+ 'comparison could be MADE: 0 when it ran — including the build with every easing',
1301
+ 'reversed, which is the defect this command exists for — 1 when it could not (frames',
1302
+ 'that are not there, a skin the frames do not record, a candidate that will not load),',
1303
+ '2 on the flags.',
1304
+ '',
1305
+ 'So read a figure against a floor you measured yourself: render the first green build',
1306
+ 'and keep its frames, then check every later build against them. The identity run of',
1307
+ 'that pair is the floor, and it is not zero — docs/AUTHORING.md §9.2 states it, what',
1308
+ 'it comes from, and which column separates a wrong curve from a moved key.',
1309
+ ],
1310
+ },
1311
+ {
1312
+ name: 'bench',
1313
+ runtime: { for: 'the gate runs before anything is measured, and --bones poses the rung\'s reference skeletons through it' },
1314
+ spineFormat: false,
1315
+ usage: [`rigc bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>] [flags]`],
1316
+ flags: ['candidate', 'atlas', 'frames', 'profile', 'bones', 'all-frames', 'all-bones', 'json'],
1317
+ overrides: {
1318
+ bones: {
1319
+ meaning:
1320
+ 'also run the stage-3 per-frame bone world-transform distance against each of the rung\'s reference ' +
1321
+ `skeletons, with this correspondence (or \`identity\`), at ${PROTOCOL_FPS} fps. Reports; gates nothing — ` +
1322
+ 'for another sampling rate call `rigc bonedist` directly, where --fps means only that',
1323
+ },
1324
+ },
1325
+ },
1326
+ {
1327
+ name: 'bonedist',
1328
+ runtime: { for: 'it poses both skeletons through it, by design' },
1329
+ spineFormat: false,
1330
+ usage: [
1331
+ `rigc bonedist --candidate <dir | skeleton.json> --reference <dir | skeleton.json> --bones <path | ${IDENTITY_CORRESPONDENCE}> [--fps ${PROTOCOL_FPS}] [--all-bones] [--json <out>]`,
1332
+ ],
1333
+ flags: ['candidate', 'atlas', 'reference', 'reference-atlas', 'bones', 'fps', 'all-bones', 'json'],
1334
+ overrides: {
1335
+ fps: { meaning: `the rate both skeletons are sampled at, from t=0 over their own durations (default ${PROTOCOL_FPS})` },
1336
+ },
1337
+ },
1338
+ {
1339
+ name: 'render',
1340
+ runtime: false,
1341
+ spineFormat: false,
1342
+ usage: [
1343
+ 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--poser core|spine] [--out render/]',
1344
+ 'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1345
+ ],
1346
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'poser', 'out'],
1347
+ overrides: {
1348
+ out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1349
+ fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
1350
+ },
1351
+ },
1352
+ {
1353
+ name: 'preview',
1354
+ runtime: { for: "its gate line is the round trip's, and it names the pages it embeds through spine-core's atlas reader" },
1355
+ spineFormat: true,
1356
+ usage: ['rigc preview --candidate <dir | skeleton.json> [--candidate <another> …] [--animation <name>] [--out preview.html]'],
1357
+ flags: ['candidate', 'atlas', 'animation', 'out'],
1358
+ overrides: {
1359
+ candidate: {
1360
+ value: '<dir|skeleton.json>',
1361
+ meaning:
1362
+ 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path. Repeat it ' +
1363
+ 'for one page with a pane per candidate, in the order given; the same skeleton twice is refused. Each ' +
1364
+ 'header carries the line `rigc validate <dir>` prints for that candidate, measured when the page is written',
1365
+ },
1366
+ atlas: { meaning: "the candidate's atlas, when it is not beside the skeleton — one candidate only" },
1367
+ animation: {
1368
+ meaning:
1369
+ "the animation to start on (default: each candidate's own first). With several candidates every one of " +
1370
+ 'them must have it, or the run is refused naming the one that does not',
1371
+ },
1372
+ out: {
1373
+ value: '<file>',
1374
+ meaning: 'the .html file to write (default `preview.html`); a directory means "the default name in here"',
1375
+ },
1376
+ },
1377
+ },
1378
+ {
1379
+ name: 'pose',
1380
+ runtime: false,
1381
+ spineFormat: false,
1382
+ usage: [
1383
+ `rigc pose --images <dir> --frame <path> [--scale ${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}] [--rotation -180,180] [--out ${DEFAULT_POSE_OUT}]`,
1384
+ ],
1385
+ flags: ['images', 'frame', 'scale', 'rotation', 'max-residual', 'out'],
1386
+ overrides: {
1387
+ images: { value: '<dir>', meaning: 'the loose part PNGs to place; every `.png` in it is a part, in name order' },
1388
+ out: {
1389
+ value: '<file>',
1390
+ meaning: `the .json report to write (default \`${DEFAULT_POSE_OUT}\`); a directory means "the default name in here"`,
1391
+ },
1392
+ },
1393
+ },
1394
+ {
1395
+ name: 'chainfit',
1396
+ runtime: false,
1397
+ spineFormat: false,
1398
+ usage: [
1399
+ `rigc chainfit --candidate <dir | skeleton.json> --images <dir> --frame <path> [--anchor pose.json] [--out ${DEFAULT_CHAINFIT_OUT}]`,
1400
+ ],
1401
+ flags: [
1402
+ 'candidate',
1403
+ 'images',
1404
+ 'frame',
1405
+ 'anchor',
1406
+ 'hinge',
1407
+ 'stretch',
1408
+ 'min-visible',
1409
+ 'max-residual',
1410
+ 'passes',
1411
+ 'anchor-residual',
1412
+ 'inward-lever',
1413
+ 'scale',
1414
+ 'rotation',
1415
+ 'out',
1416
+ ],
1417
+ overrides: {
1418
+ images: {
1419
+ value: '<dir>',
1420
+ meaning:
1421
+ "where each attachment's image name resolves to a loose PNG. ⚠️ NOT a part list the way `pose --images` " +
1422
+ 'is one — the candidate decides what the parts are, so extra PNGs in here are simply unused and a name ' +
1423
+ 'the directory lacks is refused by name',
1424
+ },
1425
+ scale: {
1426
+ meaning:
1427
+ 'the scale window the INTERNAL anchor pass searches, as frame pixels per part pixel (default ' +
1428
+ `\`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`). Refused together with --anchor, which means there is no internal pass`,
1429
+ },
1430
+ rotation: {
1431
+ meaning:
1432
+ 'the rotation window the INTERNAL anchor pass searches, in screen degrees (default `-180,180`). Refused ' +
1433
+ 'together with --anchor — the chains\' own window is --hinge',
1434
+ },
1435
+ out: {
1436
+ value: '<file>',
1437
+ meaning: `the .json report to write (default \`${DEFAULT_CHAINFIT_OUT}\`); a directory means "the default name in here"`,
1438
+ },
1439
+ },
1440
+ },
1441
+ {
1442
+ name: 'vote',
1443
+ runtime: { for: "it names each candidate's pages through spine-core's atlas reader, as preview does" },
1444
+ spineFormat: true,
1445
+ usage: [
1446
+ `rigc vote --candidate <dir | skeleton.json> --candidate <…> [--candidate …] [--animation <name>] [--out ${DEFAULT_BALLOT}]`,
1447
+ `rigc vote --record <result.json> [--ballot ${DEFAULT_BALLOT}] [--ledger ${DEFAULT_LEDGER}] [--again]`,
1448
+ ],
1449
+ flags: ['candidate', 'animation', 'out', 'record', 'ballot', 'ledger', 'again'],
1450
+ overrides: {
1451
+ candidate: {
1452
+ value: '<dir|skeleton.json>',
1453
+ meaning: `repeat it ${MIN_CANDIDATES}–${MAX_CANDIDATES} times — one compiled artifact per pane, labelled A, B, C, D in the order given`,
1454
+ },
1455
+ animation: {
1456
+ meaning:
1457
+ 'the one animation every pane plays (default: the first of candidate A). A candidate that does not have ' +
1458
+ 'it is refused — two panes playing two animations is not a comparison',
1459
+ },
1460
+ out: {
1461
+ value: '<file>',
1462
+ meaning: `the .html ballot to write (default \`${DEFAULT_BALLOT}\`); a directory means "the default name in here"`,
1463
+ },
1464
+ },
1465
+ },
1466
+ {
1467
+ name: 'skills',
1468
+ runtime: false,
1469
+ spineFormat: false,
1470
+ usage: [`rigc skills install [--dir ${DEFAULT_SKILLS_DIR}] [--copy] (every skill this package ships, where an agent host looks)`],
1471
+ flags: ['dir', 'copy'],
1472
+ notes: [
1473
+ 'the skills installed are the skills/ directory of the package this command runs from,',
1474
+ 'never whatever the working directory holds. Each becomes <dir>/<name>: a relative',
1475
+ 'symlink into that folder, or with --copy a copy of it. An entry already there that',
1476
+ 'is not a link to the same folder — or, with --copy, not the same bytes — is refused',
1477
+ 'by name, exit 1, and nothing is written; a run over only what this command made has',
1478
+ 'nothing to do and exits 0. Codex, Gemini CLI and Antigravity read',
1479
+ `<workspace>/${DEFAULT_SKILLS_DIR}; Claude Code installs the plugin instead (README,`,
1480
+ '"Install it into your agent").',
1481
+ ],
1482
+ },
1483
+ ];
1484
+
1485
+ /** Every command's name, in the order the usage lists them — what the full entry dispatches over. */
1486
+ export const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
1487
+
1488
+ /** `rigc <command> --help`: that command's own usage line(s) and flag table. */
1489
+ export function commandHelp(name: string): string {
1490
+ const doc = COMMANDS.find((c) => c.name === name);
1491
+ if (!doc) throw new Error(`internal: no help text for command "${name}"`);
1492
+ const keys = [...doc.flags, 'help'];
1493
+ const value = (key: string): string | undefined => doc.overrides?.[key]?.value ?? FLAG_VALUES[key];
1494
+ const meaning = (key: string): string => doc.overrides?.[key]?.meaning ?? FLAG_MEANINGS[key];
1495
+ const labels = keys.map((key) => `--${key}${value(key) ? ` ${value(key)}` : ''}`);
1496
+ const width = Math.max(...labels.map((l) => l.length)) + 2;
1497
+ return [
1498
+ 'usage:',
1499
+ ...doc.usage.map((u) => ` ${u}`),
1500
+ '',
1501
+ 'flags:',
1502
+ ...keys.map((key, i) => ` ${labels[i].padEnd(width)}${meaning(key)}`),
1503
+ ...(doc.notes === undefined ? [] : ['', ...doc.notes]),
1504
+ ].join('\n');
1505
+ }
1506
+
1507
+ /**
1508
+ * The usage's paragraphs under the invocation lines, each with the commands it
1509
+ * is about (issue #1052): an entry prints a paragraph only when every command
1510
+ * it names is one the entry runs, so `cli_core.ts`'s page says nothing about a
1511
+ * command it does not have, and `cli.ts`'s — which runs every one — is the
1512
+ * page it always was, byte for byte. The `--cuts` paragraph is about a flag
1513
+ * rather than a command, and is printed wherever a command takes it. The
1514
+ * `--profile` paragraph states each profile's rule count, which only the
1515
+ * validator knows (`CliEntry.profileRules`).
1516
+ */
1517
+ const USAGE_PARAGRAPHS: ReadonlyArray<{ about: readonly string[] | { flag: string }; lines: (rules: (profile: 'spine' | 'spine-html') => number) => string[] }> = [
1518
+ {
1519
+ about: ['build', 'validate', 'bench'],
1520
+ lines: (rules) => [
1521
+ 'build, validate and bench take --profile spine|spine-html:',
1522
+ ' spine is this valid Spine 4.3 that any runtime plays correctly?',
1523
+ ` THE DEFAULT — ${rules('spine')} rules, and the question the output answers when`,
1524
+ ' you import it into the Spine editor.',
1525
+ ' spine-html the above, plus this project\'s renderer and archetype policy:',
1526
+ ` all ${rules('spine-html')} rules, opt-in. Those extra ` +
1527
+ `${rules('spine-html') - rules('spine')} fire on real, correct,`,
1528
+ ' editor-produced Spine data, so they are somebody\'s policy rather',
1529
+ ' than anybody\'s validity.',
1530
+ '',
1531
+ 'Every report names the profile that judged it and lists, on PROF lines, the',
1532
+ 'rules that profile left out.',
1533
+ ],
1534
+ },
1535
+ {
1536
+ about: ['check'],
1537
+ lines: () => [
1538
+ 'check renders the candidate onto the reference frames\' own pixel grid, fitting it',
1539
+ 'there by its own drawn pixels, and compares. It reads the frames and never the',
1540
+ 'reference skeleton, so it belongs INSIDE an authoring loop — the validator cannot',
1541
+ 'see a wrong animation and this can. See `rigc check --help` for its flags.',
1542
+ ],
1543
+ },
1544
+ {
1545
+ about: ['render', 'preview'],
1546
+ lines: () => [
1547
+ 'render and preview are how you LOOK at a build, and they need no reference at all:',
1548
+ ' rigc render --candidate <the dir build --out wrote> PNG frames + a contact sheet',
1549
+ ' rigc preview --candidate <the same dir> one .html file that plays it',
1550
+ 'A rig with its head off its torso passes the gate and steps cleanly — the offsets',
1551
+ 'are the ones you asked for — so looking is the only thing that catches it. render',
1552
+ 'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
1553
+ 'it in the official Spine Web Player, which is also the interop proof.',
1554
+ ],
1555
+ },
1556
+ {
1557
+ about: ['ingest'],
1558
+ lines: () => [
1559
+ 'ingest runs build backwards: it reads a Spine 4.3 skeleton.json and writes the rig',
1560
+ 'spec and motion spec that rebuild it, so an existing skeleton becomes a starting',
1561
+ 'point instead of something to retype:',
1562
+ ' rigc ingest hero.json --out specs/ --images parts/ rig.json + motion.json',
1563
+ 'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
1564
+ 'It reads the skeleton and nothing else — no .spine project, no binary .skel, no',
1565
+ 'atlas — so how the spec reaches the art is the caller\'s (--art) and is not guessed.',
1566
+ 'A setup stage the skeleton states is read; one it does not state is carried as',
1567
+ 'absent, and --stage is how a caller adds a box to such a file — beside a box the',
1568
+ 'file states, the flag is refused rather than ignored. --images <dir> WRITES the rig',
1569
+ 'spec\'s own images directory, relative to --out, so the rebuild needs no flag.',
1570
+ 'Everything the spec format cannot hold is printed as a named finding and',
1571
+ 'exits non-zero, with both files still written, because a spec plus a list of what',
1572
+ 'is missing from it beats no spec at all.',
1573
+ ],
1574
+ },
1575
+ {
1576
+ about: ['pose'],
1577
+ lines: () => [
1578
+ 'pose runs the other way round from everything above: it reads a picture you already',
1579
+ 'have — one key pose — and reports where each loose part PNG sits in it (x, y, rotation,',
1580
+ 'scale) so an agent can state those poses in a spec by construction:',
1581
+ ' rigc pose --images parts/ --frame poseA.png pose.json, one entry per part',
1582
+ 'It grades nothing and no pass bar attaches to its numbers. The residual is a trust',
1583
+ 'signal, and where two placements are equally good it reports BOTH rather than picking —',
1584
+ 'two identical limbs look exactly like that. A part that matches nowhere, a part the',
1585
+ 'canvas cannot contain and a part whose rotation is a free degree of freedom are each',
1586
+ 'named as such. See `rigc pose --help`.',
1587
+ ],
1588
+ },
1589
+ {
1590
+ about: ['pose', 'chainfit'],
1591
+ lines: () => [
1592
+ 'chainfit reads the half of that picture pose refuses. It is the same question with',
1593
+ 'one more input — the candidate rig — and that input buys two things: draw order, so',
1594
+ 'the pixels another part covers are EXCLUDED from a part\'s residual instead of',
1595
+ 'charged to it, and hierarchy, so a child of a placed bone is searched over one hinge',
1596
+ 'instead of four degrees of freedom:',
1597
+ ' rigc chainfit --candidate build/ --images parts/ --frame poseA.png',
1598
+ 'Every residual is over the part\'s VISIBLE pixels and comes with the `visibleShare` it',
1599
+ 'was computed on, so a mostly-hidden answer carries its own uncertainty. It grades',
1600
+ 'nothing either: a part too far behind the others is refused by the visibility floor,',
1601
+ 'a limb with no trusted part on it or above it is refused `no-anchor`, and two hinge',
1602
+ 'answers that explain the picture equally well are both reported. See',
1603
+ '`rigc chainfit --help`.',
1604
+ ],
1605
+ },
1606
+ {
1607
+ about: ['preview', 'vote'],
1608
+ lines: () => [
1609
+ 'vote is the same page with two to four builds in it and an answer coming back:',
1610
+ ' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
1611
+ ' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
1612
+ 'Reach for it where the instruments have run out — a choice with no reference behind',
1613
+ 'it, two fits that measure the same. The panes carry no paths, a tie is a recorded',
1614
+ 'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
1615
+ 'refused by name instead of appended.',
1616
+ ],
1617
+ },
1618
+ {
1619
+ about: ['skills'],
1620
+ lines: () => [
1621
+ 'skills install puts the agent skills this package ships where an agent host looks',
1622
+ 'for them, since none of them reads node_modules:',
1623
+ ` rigc skills install relative links in ${DEFAULT_SKILLS_DIR}, which Codex, Gemini CLI`,
1624
+ ' and Antigravity read; --copy writes the folders instead',
1625
+ 'An entry already there that this command did not make is refused by name and',
1626
+ 'nothing is written; a second run has nothing to do. See `rigc skills --help`.',
1627
+ ],
1628
+ },
1629
+ {
1630
+ about: { flag: 'cuts' },
1631
+ lines: () => [
1632
+ 'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
1633
+ ' "manifest": "..." (optional) } }, with every path',
1634
+ 'resolved relative to the cuts.json file itself.',
1635
+
1636
+ ],
1637
+ },
1638
+ ];
1639
+
1640
+ /**
1641
+ * The usage page of an entry that runs `docs` — the commands' invocation
1642
+ * lines, then every paragraph about only those commands (`USAGE_PARAGRAPHS`).
1643
+ * `checkout` is the file a source checkout runs this entry as.
1644
+ */
1645
+ export function usageText(docs: readonly CommandDoc[], checkout: string, rules: ((profile: 'spine' | 'spine-html') => number) | undefined): string {
1646
+ const names = new Set(docs.map((doc) => doc.name));
1647
+ const takes = (flag: string): boolean => docs.some((doc) => doc.flags.includes(flag));
1648
+ const lines = [
1649
+ 'rigc — the rig compiler',
1650
+ '',
1651
+ checkout === 'cli.ts'
1652
+ ? '(from a source checkout: `bun cli.ts <command>` is the same as `rigc <command>`)'
1653
+ : `(from a source checkout: \`bun ${checkout} <command>\` runs the commands below, which link nothing of spine-core; \`bun cli.ts <command>\` runs every command)`,
1654
+ '',
1655
+ 'usage:',
1656
+ ...docs.flatMap((c) => c.usage.map((u) => ` ${u}`)),
1657
+ '',
1658
+ ' rigc <command> --help that command\'s own flag table',
1659
+ ' rigc --version print the installed version (-v works too)',
1660
+ ];
1661
+ // Read only by the --profile paragraph, which only an entry running a command that takes --profile prints.
1662
+ const counts =
1663
+ rules ??
1664
+ ((): number => {
1665
+ throw new Error('internal: the --profile paragraph is printed and this entry states no rule counts');
1666
+ });
1667
+ for (const paragraph of USAGE_PARAGRAPHS) {
1668
+ const about = paragraph.about;
1669
+ const shown = 'flag' in about ? takes(about.flag) : about.every((name) => names.has(name));
1670
+ if (shown) lines.push('', ...paragraph.lines(counts));
1671
+ }
1672
+ return lines.join('\n');
1673
+ }
1674
+
1675
+ /**
1676
+ * One `DROP` line, written once because two outcomes print it.
1677
+ *
1678
+ * A build that succeeds prints it in its report; a build that REFUSES prints it
1679
+ * under the refusal (issue #671), and the two have to be the same line or the
1680
+ * failing run would be quoting a different fact from the one the green run
1681
+ * shows. What it names — a file or a region — is `droppedStateReason`'s, in the
1682
+ * compiler, beside the code that decided which of the two was consulted.
1683
+ */
1684
+ export function dropLine(dropped: DroppedState): string {
1685
+ return ` DROP ${dropped.slot}/${dropped.state}: ${droppedStateReason(dropped)} (state not emitted)`;
1686
+ }
1687
+
1688
+ // ---------------------------------------------------------------------------
1689
+ // the dispatch — one for every entry (issue #1052)
1690
+ // ---------------------------------------------------------------------------
1691
+
1692
+ /** What `rigc <command> …` hands a command's body: the flags, every occurrence of a repeatable one, and the positionals. */
1693
+ export interface CommandArgs {
1694
+ flags: Record<string, string>;
1695
+ lists: Record<string, string[]>;
1696
+ positional: string[];
1697
+ }
1698
+
1699
+ /** One command's body, as an entry registers it. */
1700
+ export type CommandRun = (args: CommandArgs) => void;
1701
+
1702
+ /**
1703
+ * A refusal a body throws that the shared chain below does not know, because
1704
+ * the class lives in a module only one entry links — `bonedist`'s, whose
1705
+ * module poses through spine-core. Printed as `prefix` + the message, then
1706
+ * exit `status`.
1707
+ */
1708
+ export interface CliRefusal {
1709
+ is(err: unknown): boolean;
1710
+ prefix: string;
1711
+ status: number;
1712
+ }
1713
+
1714
+ /** An entry: the bodies it registers, and what only its side of the seam knows. */
1715
+ export interface CliEntry {
1716
+ /** The file a source checkout runs this entry as — what the usage's first line names. */
1717
+ checkout: string;
1718
+ /**
1719
+ * Whether this entry links spine-core: `true` runs every command `COMMANDS`
1720
+ * documents, `false` only those whose `runtime` is `false` — the set is read
1721
+ * off the table, never listed beside it.
1722
+ */
1723
+ linksRuntime: boolean;
1724
+ /** Every command this entry runs, by name — exactly its set, or the run refuses to start. */
1725
+ runs: Readonly<Record<string, CommandRun>>;
1726
+ /** Each profile's rule count, for the `--profile` paragraph; the validator's, so only an entry that links it states it. */
1727
+ profileRules?: (profile: 'spine' | 'spine-html') => number;
1728
+ /** The refusals of bodies only this entry registers (`CliRefusal`). */
1729
+ refusals?: readonly CliRefusal[];
1730
+ }
1731
+
1732
+ /** The commands an entry runs: every documented one, or — linking nothing of the runtime — those whose `runtime` is `false`. */
1733
+ export function entryCommands(linksRuntime: boolean): CommandDoc[] {
1734
+ return COMMANDS.filter((doc) => linksRuntime || doc.runtime === false);
1735
+ }
1736
+
1737
+ /**
1738
+ * A command `COMMANDS` documents and this entry does not run, because its body
1739
+ * reaches spine-core and the entry links none of it — refused naming what the
1740
+ * command runs through the runtime for, and the commands the entry does run.
1741
+ * A `SpineRuntimeError`, so it is printed and exits as the export an entry
1742
+ * cannot pose is.
1743
+ */
1744
+ function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[]): SpineRuntimeError {
1745
+ const needs = doc.runtime === false ? 'nothing' : doc.runtime.for;
1746
+ return new SpineRuntimeError(
1747
+ `\`${doc.name}\` runs through spine-core (${needs}), and the runtime could not be used: ${SPINE_SIDE_ABSENT}. ` +
1748
+ `The commands this entry runs are ${runs.join(', ')}; \`bun cli.ts ${doc.name}\` runs it`,
1749
+ );
1750
+ }
1751
+
1752
+ /**
1753
+ * Run `argv` (`process.argv` without the runtime and the script) through
1754
+ * `entry`: the usage, `--version`, a command's `--help`, the command, and
1755
+ * every refusal printed and mapped to its exit code — the dispatch `cli.ts`
1756
+ * always had, written once for both entries.
1757
+ */
1758
+ export function runCli(entry: CliEntry, argv: readonly string[]): void {
1759
+ const docs = entryCommands(entry.linksRuntime);
1760
+ const known = docs.map((doc) => doc.name);
1761
+ const registered = Object.keys(entry.runs).sort();
1762
+ if (JSON.stringify([...known].sort()) !== JSON.stringify(registered)) {
1763
+ throw new Error(`internal: this entry runs [${known.join(', ')}] by the command table and registers [${registered.join(', ')}]`);
1764
+ }
1765
+ const USAGE = usageText(docs, entry.checkout, entry.profileRules);
1766
+ const [command, ...rest] = argv;
1767
+ try {
1768
+ if (command === undefined) {
1769
+ console.error(USAGE);
1770
+ process.exit(2);
1771
+ }
1772
+ if (command === '--version' || command === '-v') {
1773
+ console.log(readVersion());
1774
+ process.exit(0);
1775
+ }
1776
+ if (command === '--help' || command === '-h') {
1777
+ console.log(USAGE);
1778
+ process.exit(0);
1779
+ }
1780
+ if (!known.includes(command)) {
1781
+ const elsewhere = COMMANDS.find((doc) => doc.name === command);
1782
+ if (elsewhere !== undefined) throw commandNeedsRuntime(elsewhere, known);
1783
+ throw new UsageError(`unknown command: ${command}`);
1784
+ }
1785
+
1786
+ const { flags, lists, positional } = parseArgs([...rest], REPEATABLE_FLAGS[command]);
1787
+ if (flags.help !== undefined) {
1788
+ console.log(commandHelp(command));
1789
+ process.exit(0);
1790
+ }
1791
+ entry.runs[command]({ flags, lists, positional });
1792
+ } catch (err) {
1793
+ refuse(err, command, USAGE, entry);
1794
+ }
1795
+ }
1796
+
1797
+ /**
1798
+ * Every refusal a command can end on, printed and mapped to its exit code —
1799
+ * the chain `cli.ts`'s dispatch always ended on, moved here unchanged (issue
1800
+ * #1052) but for `bonedist`'s refusal, which its entry hands over
1801
+ * (`CliEntry.refusals`). Anything else is not a refusal and is thrown on.
1802
+ */
1803
+ function refuse(err: unknown, command: string | undefined, USAGE: string, entry: CliEntry): never {
1804
+ if (err instanceof UsageError) {
1805
+ console.error(`rigc: ${err.message}\n\n${USAGE}`);
1806
+ process.exit(2);
1807
+ }
1808
+ // A ballot refuses on its arguments, like a usage error, but its messages are
1809
+ // long enough that reprinting the whole usage under them buries the reason.
1810
+ if (err instanceof BallotError) {
1811
+ console.error(`rigc vote: ${err.message}`);
1812
+ process.exit(2);
1813
+ }
1814
+ if (err instanceof CompileError) {
1815
+ console.error(`rigc compile error: ${err.message}`);
1816
+ // The drops the compile recorded before it stopped, in the same line the
1817
+ // green build prints (issue #671). They were reported from the compile
1818
+ // RESULT alone, so the run that failed BECAUSE a file was missing was the
1819
+ // one run that never named the file. On stderr with the refusal rather than
1820
+ // on stdout, so redirecting one stream does not separate a fact from the
1821
+ // sentence it explains.
1822
+ for (const dropped of err.droppedStates ?? []) console.error(dropLine(dropped));
1823
+ process.exit(1);
1824
+ }
1825
+ if (err instanceof CheckError) {
1826
+ console.error(`rigc check error: ${err.message}`);
1827
+ process.exit(1);
1828
+ }
1829
+ // The refusals of bodies only this entry registers — `bonedist`'s, whose module poses through spine-core — checked
1830
+ // where the chain always checked them. Every class in it is unrelated to every other, so the place is not load-bearing.
1831
+ for (const refusal of entry.refusals ?? []) {
1832
+ if (refusal.is(err)) {
1833
+ console.error(`${refusal.prefix}${(err as Error).message}`);
1834
+ process.exit(refusal.status);
1835
+ }
1836
+ }
1837
+ // Like a usage error in kind — a missing directory, an unreadable frame — but
1838
+ // its messages name a path and a reason, and reprinting the whole usage under
1839
+ // them buries that.
1840
+ if (err instanceof PoseError) {
1841
+ console.error(`rigc pose: ${err.message}`);
1842
+ process.exit(2);
1843
+ }
1844
+ // A refusal of the invocation, like the two below it, and exit 2 for the same
1845
+ // reason: nothing was posed and nothing was written, so it is the command line
1846
+ // that has to change (issue #697).
1847
+ if (err instanceof ExplainError) {
1848
+ console.error(`rigc explain: ${err.message}`);
1849
+ process.exit(2);
1850
+ }
1851
+ // Same kind as a PoseError, and printed the same way for the same reason: the
1852
+ // messages name a path, a bone or an attachment, and reprinting the whole
1853
+ // usage under them buries the one line that says what to change.
1854
+ if (err instanceof ChainFitError) {
1855
+ console.error(`rigc chainfit: ${err.message}`);
1856
+ process.exit(2);
1857
+ }
1858
+ // A usage error in kind — an option that contradicts the file it was given —
1859
+ // and exit 2 for that reason rather than 1: nothing was compiled and nothing
1860
+ // was written, so it is the invocation that has to change (issue #626). It is
1861
+ // raised in `src/ingest.ts` rather than here because the library caller who
1862
+ // passes the same contradiction deserves the same refusal, and one rule in one
1863
+ // place is what stops the two from drifting apart.
1864
+ if (err instanceof IngestError) {
1865
+ console.error(`rigc ingest: ${err.message}`);
1866
+ process.exit(2);
1867
+ }
1868
+ // A page or frame that is not a PNG rigc can read, from any command that
1869
+ // opens one (`render`, `check`, `preview`, …) — issue #732. The sentence is
1870
+ // the one reader's and already names the file, what it is and what rigc
1871
+ // reads; a stack under it is the tool describing its own internals instead.
1872
+ // Exit 1, like a compile error: the invocation was fine, a file was not.
1873
+ // A pose that is not finite (issues #864, #873): the invocation was fine and
1874
+ // the skeleton posed a NaN or an infinity, so exit 1 like a file that is not
1875
+ // a PNG. Raised by the geometry export and by the framing — the one sentence
1876
+ // naming the bone or vertex and its value — before the first file is written.
1877
+ // A pose whose every drawn vertex sits at one point (issue #997), from
1878
+ // `render` or `check`: exit 2 like *nothing to draw*, the other framing
1879
+ // refusal, because the usual fix is the invocation's — `--skin` — and nothing
1880
+ // was written. A `GeometryError` in kind, so it is caught before that.
1881
+ if (err instanceof UnframeablePoseError) {
1882
+ console.error(`rigc ${command}: ${err.message}`);
1883
+ process.exit(2);
1884
+ }
1885
+ if (err instanceof GeometryError) {
1886
+ console.error(`rigc render: ${err.message}`);
1887
+ process.exit(1);
1888
+ }
1889
+ // `--poser core` on an input the core cannot carry (issue #968): a refusal of
1890
+ // the invocation, nothing written, and the message names the input and why —
1891
+ // the usage under it would bury that. Without the flag the same refusal is a
1892
+ // fallback to spine-core, named on the render's `poser` line instead.
1893
+ if (err instanceof PoserChoiceError) {
1894
+ console.error(`rigc ${command}: ${err.message}`);
1895
+ process.exit(2);
1896
+ }
1897
+ if (err instanceof NotAPngError) {
1898
+ console.error(`rigc: ${err.message}`);
1899
+ process.exit(1);
1900
+ }
1901
+ // An input posed through spine-core — a Spine export, `--poser spine`, a
1902
+ // fallback — on a run where the runtime cannot be used (issue #1014). The
1903
+ // sentence names the input and why it needs the runtime; exit 1, like a file
1904
+ // that is not a PNG: the invocation was fine and the run could not pose it.
1905
+ // A candidate with no atlas beside it that has to be read through one (issue
1906
+ // #1020): a refusal of the invocation like a missing atlas on an export, exit
1907
+ // 2 and nothing written, and the message names the file and why it is needed.
1908
+ if (err instanceof CandidateAtlasError) {
1909
+ console.error(`rigc ${command}: ${err.message}`);
1910
+ process.exit(2);
1911
+ }
1912
+ // A skeleton spine-core could not load against the atlas beside it (issue #1033) — on a rigc build, a
1913
+ // skeleton.json or an atlas from another build beside this one's model document. The same class as the
1914
+ // missing atlas above: nothing was posed or written, and it is the directory the command was pointed at that
1915
+ // has to change. The message names both files, the reason the runtime drew them and the runtime's own words.
1916
+ // Since issue #1042 also `bonedist` and `bench --bones` on either side, and a page a candidate is drawn from that
1917
+ // is not there, whichever poser draws it.
1918
+ if (err instanceof CandidatePairError) {
1919
+ console.error(`rigc ${command}: ${err.message}`);
1920
+ process.exit(2);
1921
+ }
1922
+ if (err instanceof SpineRuntimeError) {
1923
+ console.error(`rigc ${command}: ${err.message}`);
1924
+ process.exit(1);
1925
+ }
1926
+ // An install refused before its first write (issue #831): the invocation was
1927
+ // fine and an entry on disk was not what this command would write, so exit 1
1928
+ // like a file that is not a PNG. The message names every such entry, what is
1929
+ // there and what was required; the usage under it would bury that.
1930
+ if (err instanceof SkillsInstallError) {
1931
+ console.error(`rigc skills install: ${err.message}`);
1932
+ process.exit(1);
1933
+ }
1934
+ throw err;
1935
+ }