rig-c 0.0.0-stage → 2.20.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 (213) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +13 -0
  3. package/LICENSE +30 -0
  4. package/NOTICE.md +145 -0
  5. package/README.md +817 -3
  6. package/bin/rigc.cjs +83 -0
  7. package/cli.ts +61 -0
  8. package/cli_core.ts +46 -0
  9. package/docs/AUTHORING.md +9923 -0
  10. package/docs/FACE.md +1948 -0
  11. package/docs/INGEST.md +1488 -0
  12. package/docs/MOTION.md +1241 -0
  13. package/docs/PROMPTING.md +109 -0
  14. package/docs/RIGGING.md +1441 -0
  15. package/docs/SPEC_COVERAGE.md +357 -0
  16. package/package.json +108 -4
  17. package/skills/rigc/SKILL.md +133 -0
  18. package/skills/rigc-face/SKILL.md +60 -0
  19. package/skills/rigc-ingest/SKILL.md +78 -0
  20. package/skills/rigc-motion/SKILL.md +51 -0
  21. package/skills/rigc-rigging/SKILL.md +49 -0
  22. package/src/areaband.ts +159 -0
  23. package/src/assertions/bodies/a01.ts +23 -0
  24. package/src/assertions/bodies/a02.ts +21 -0
  25. package/src/assertions/bodies/a03.ts +27 -0
  26. package/src/assertions/bodies/a04.ts +40 -0
  27. package/src/assertions/bodies/a05.ts +56 -0
  28. package/src/assertions/bodies/a06.ts +245 -0
  29. package/src/assertions/bodies/a07.ts +68 -0
  30. package/src/assertions/bodies/a08.ts +76 -0
  31. package/src/assertions/bodies/a09.ts +82 -0
  32. package/src/assertions/bodies/a10.ts +116 -0
  33. package/src/assertions/bodies/a11.ts +15 -0
  34. package/src/assertions/bodies/a12.ts +30 -0
  35. package/src/assertions/bodies/a13.ts +51 -0
  36. package/src/assertions/bodies/a14.ts +35 -0
  37. package/src/assertions/bodies/a15.ts +97 -0
  38. package/src/assertions/bodies/a16.ts +24 -0
  39. package/src/assertions/bodies/a17.ts +26 -0
  40. package/src/assertions/bodies/a18.ts +62 -0
  41. package/src/assertions/bodies/a19.ts +404 -0
  42. package/src/assertions/bodies/a20.ts +122 -0
  43. package/src/assertions/bodies/a21.ts +190 -0
  44. package/src/assertions/bodies/a22.ts +39 -0
  45. package/src/assertions/bodies/a23.ts +305 -0
  46. package/src/assertions/bodies/a24.ts +68 -0
  47. package/src/assertions/bodies/a25.ts +39 -0
  48. package/src/assertions/bodies/a26.ts +61 -0
  49. package/src/assertions/bodies/a27.ts +33 -0
  50. package/src/assertions/bodies/a28.ts +70 -0
  51. package/src/assertions/bodies/a29.ts +34 -0
  52. package/src/assertions/bodies/a30.ts +50 -0
  53. package/src/assertions/bodies/a31.ts +61 -0
  54. package/src/assertions/bodies/a32.ts +44 -0
  55. package/src/assertions/bodies/a33.ts +110 -0
  56. package/src/assertions/bodies/a34.ts +133 -0
  57. package/src/assertions/bodies/a35.ts +160 -0
  58. package/src/assertions/bodies/a36.ts +81 -0
  59. package/src/assertions/bodies/a37.ts +77 -0
  60. package/src/assertions/bodies/a38.ts +73 -0
  61. package/src/assertions/bodies/a39.ts +303 -0
  62. package/src/assertions/bodies/a40.ts +128 -0
  63. package/src/assertions/bodies/a42.ts +97 -0
  64. package/src/assertions/bodies/a43.ts +181 -0
  65. package/src/assertions/bodies/a44.ts +23 -0
  66. package/src/assertions/bodies/a45.ts +172 -0
  67. package/src/assertions/bodies/a46.ts +224 -0
  68. package/src/assertions/bodies/a47.ts +126 -0
  69. package/src/assertions/bodies/a48.ts +83 -0
  70. package/src/assertions/bodies/a49.ts +81 -0
  71. package/src/assertions/bodies/a50.ts +97 -0
  72. package/src/assertions/constraint_words.ts +169 -0
  73. package/src/assertions/emitted/index.ts +148 -0
  74. package/src/assertions/facts/animated_bones.ts +30 -0
  75. package/src/assertions/facts/animation_durations.ts +37 -0
  76. package/src/assertions/facts/atlas_pages.ts +19 -0
  77. package/src/assertions/facts/atlas_regions.ts +52 -0
  78. package/src/assertions/facts/bone_timelines.ts +37 -0
  79. package/src/assertions/facts/constraint_targets.ts +56 -0
  80. package/src/assertions/facts/constraints.ts +155 -0
  81. package/src/assertions/facts/deform_survey.ts +27 -0
  82. package/src/assertions/facts/event_keys.ts +55 -0
  83. package/src/assertions/facts/linked_meshes.ts +38 -0
  84. package/src/assertions/facts/mesh_attachments.ts +100 -0
  85. package/src/assertions/facts/region_joins.ts +34 -0
  86. package/src/assertions/facts/sequences.ts +85 -0
  87. package/src/assertions/facts/skeleton_roster.ts +45 -0
  88. package/src/assertions/facts/skin_entries.ts +37 -0
  89. package/src/assertions/facts/skin_members.ts +53 -0
  90. package/src/assertions/facts/slider_composition.ts +78 -0
  91. package/src/assertions/facts/slot_colour.ts +43 -0
  92. package/src/assertions/facts/stage.ts +27 -0
  93. package/src/assertions/facts/stage_box.ts +65 -0
  94. package/src/assertions/facts/stepped_poses.ts +74 -0
  95. package/src/assertions/facts/two_colour.ts +52 -0
  96. package/src/assertions/facts/vertex_polygons.ts +53 -0
  97. package/src/assertions/footprints.ts +367 -0
  98. package/src/assertions/harness.ts +109 -0
  99. package/src/assertions/inward_advance.ts +58 -0
  100. package/src/assertions/kinds.ts +105 -0
  101. package/src/assertions/mesh_kinds.ts +56 -0
  102. package/src/assertions/model/animated_bones.ts +38 -0
  103. package/src/assertions/model/animation_durations.ts +57 -0
  104. package/src/assertions/model/atlas_pages.ts +15 -0
  105. package/src/assertions/model/atlas_regions.ts +76 -0
  106. package/src/assertions/model/bone_timelines.ts +58 -0
  107. package/src/assertions/model/constraint_targets.ts +82 -0
  108. package/src/assertions/model/constraints.ts +233 -0
  109. package/src/assertions/model/declared.ts +125 -0
  110. package/src/assertions/model/deform_survey.ts +24 -0
  111. package/src/assertions/model/event_keys.ts +45 -0
  112. package/src/assertions/model/given.ts +45 -0
  113. package/src/assertions/model/index.ts +398 -0
  114. package/src/assertions/model/linked_meshes.ts +24 -0
  115. package/src/assertions/model/mesh_attachments.ts +119 -0
  116. package/src/assertions/model/parse.ts +146 -0
  117. package/src/assertions/model/region_joins.ts +67 -0
  118. package/src/assertions/model/runtime_timelines.ts +78 -0
  119. package/src/assertions/model/sequences.ts +157 -0
  120. package/src/assertions/model/skeleton_roster.ts +23 -0
  121. package/src/assertions/model/skin_entries.ts +69 -0
  122. package/src/assertions/model/skin_members.ts +64 -0
  123. package/src/assertions/model/slider_composition.ts +193 -0
  124. package/src/assertions/model/slot_colour.ts +81 -0
  125. package/src/assertions/model/stage.ts +28 -0
  126. package/src/assertions/model/stage_box.ts +51 -0
  127. package/src/assertions/model/stepped_poses.ts +105 -0
  128. package/src/assertions/model/two_colour.ts +61 -0
  129. package/src/assertions/model/vertex_polygons.ts +72 -0
  130. package/src/assertions/reasons.ts +129 -0
  131. package/src/assertions/region_lookups.ts +61 -0
  132. package/src/assertions/report.ts +189 -0
  133. package/src/assertions/values.ts +39 -0
  134. package/src/atlas.ts +2870 -0
  135. package/src/ballot.ts +866 -0
  136. package/src/bonedist.ts +643 -0
  137. package/src/chainfit.ts +2752 -0
  138. package/src/chains.ts +170 -0
  139. package/src/check.ts +4303 -0
  140. package/src/checkpics.ts +295 -0
  141. package/src/cli/core_commands.ts +1627 -0
  142. package/src/cli/repack.ts +414 -0
  143. package/src/cli/shared.ts +2776 -0
  144. package/src/cli/spine_commands.ts +820 -0
  145. package/src/compile.ts +9414 -0
  146. package/src/core/additive.ts +458 -0
  147. package/src/core/animation.ts +1050 -0
  148. package/src/core/clipping.ts +696 -0
  149. package/src/core/constraints.ts +1876 -0
  150. package/src/core/constraints_path.ts +964 -0
  151. package/src/core/constraints_physics.ts +881 -0
  152. package/src/core/constraints_slider.ts +635 -0
  153. package/src/core/deform.ts +613 -0
  154. package/src/core/draw_order.ts +125 -0
  155. package/src/core/events.ts +135 -0
  156. package/src/core/hooks.ts +249 -0
  157. package/src/core/index.ts +1400 -0
  158. package/src/core/raw.ts +739 -0
  159. package/src/core/skins.ts +129 -0
  160. package/src/core/uvs.ts +469 -0
  161. package/src/core/vertices.ts +490 -0
  162. package/src/core/walk.ts +197 -0
  163. package/src/core/world.ts +289 -0
  164. package/src/correspondence.ts +15 -0
  165. package/src/deformbuild.ts +60 -0
  166. package/src/deformgen.ts +630 -0
  167. package/src/deformmeasure.ts +732 -0
  168. package/src/deformreport.ts +373 -0
  169. package/src/deformstructure.ts +386 -0
  170. package/src/deformsurvey.ts +2162 -0
  171. package/src/depth.ts +784 -0
  172. package/src/diff.ts +2252 -0
  173. package/src/emit.ts +134 -0
  174. package/src/emit_spine.ts +854 -0
  175. package/src/errors.ts +53 -0
  176. package/src/framing.ts +819 -0
  177. package/src/generation.ts +139 -0
  178. package/src/ingest.ts +2293 -0
  179. package/src/json-position.ts +253 -0
  180. package/src/keyorder.ts +587 -0
  181. package/src/keys.ts +486 -0
  182. package/src/ladder.ts +121 -0
  183. package/src/mesh.ts +2382 -0
  184. package/src/meshcompare.ts +1188 -0
  185. package/src/meshquality.ts +2042 -0
  186. package/src/meshrasters.ts +944 -0
  187. package/src/meshreduce.ts +1425 -0
  188. package/src/model.ts +1245 -0
  189. package/src/motion.ts +809 -0
  190. package/src/nonfinite.ts +54 -0
  191. package/src/package_meta.ts +48 -0
  192. package/src/png.ts +297 -0
  193. package/src/pose.ts +2324 -0
  194. package/src/preview.ts +434 -0
  195. package/src/region_joins.ts +54 -0
  196. package/src/render.ts +1013 -0
  197. package/src/render_core.ts +871 -0
  198. package/src/render_shared.ts +2958 -0
  199. package/src/repack.ts +495 -0
  200. package/src/rig.ts +2941 -0
  201. package/src/slots.ts +892 -0
  202. package/src/spine_side.ts +138 -0
  203. package/src/timelines.ts +837 -0
  204. package/src/trackgen.ts +364 -0
  205. package/src/transform.ts +310 -0
  206. package/src/types.ts +1797 -0
  207. package/src/validate.ts +3875 -0
  208. package/tools/contact.ts +126 -0
  209. package/tools/editor_roundtrip.ts +1641 -0
  210. package/tools/font5x7.ts +101 -0
  211. package/tools/measure_contact_depth.ts +105 -0
  212. package/tools/plate.ts +508 -0
  213. package/tools/png_probe.mjs +72 -0
@@ -0,0 +1,820 @@
1
+ /**
2
+ * The bodies of the commands that run through spine-core — `build`,
3
+ * `validate` and `bench` (the gate is the round trip), `bonedist` (it poses
4
+ * both skeletons through the runtime by design), `preview` and `vote` (the
5
+ * page names its atlas pages through the runtime's reader, and its gate line
6
+ * is the round trip's) — moved here unchanged from `cli.ts` (issue #1052, step
7
+ * 4e of #380), with the helpers only they call. Only `cli.ts` imports this
8
+ * module (`SPINE_COMMAND_RUNS`); an entry that does not links nothing of the
9
+ * runtime, and refuses these commands naming what each needs it for.
10
+ */
11
+ import { type BallotCandidateInput, type BallotInput, buildBallot, ledgerLineText, MAX_CANDIDATES, MIN_CANDIDATES, parseLedger, readBallotManifest, resultFilename, TIE, verifyResult, VOTE_RULES } from '../ballot.ts';
12
+ import { boneDistance, boneDistLines, type BoneDistReport } from '../bonedist.ts';
13
+ import { IDENTITY_CORRESPONDENCE } from '../correspondence.ts';
14
+ import { checkLines, type CheckReport } from '../check.ts';
15
+ import { compile } from '../compile.ts';
16
+ import { diffLines, type DiffReport, diffSkeletons, reportedFigures, sectionFigures } from '../diff.ts';
17
+ import { findRung, RUNG_IDS, type RungSkeleton } from '../ladder.ts';
18
+ import { buildPreview, buildPreviewPanes, PLAYER_LINE, type PreviewGate, type PreviewInput, type PreviewPage } from '../preview.ts';
19
+ import { atlasPageNames } from '../render.ts';
20
+ import { BoneDistError } from '../bonedist.ts';
21
+ import { assertionCountForProfile, CLI_DEFAULT_PROFILE, reportLines, validate, type ValidateProfile } from '../validate.ts';
22
+ import { type CliRefusal, type CommandRun, DEFAULT_BALLOT, PACKAGE_ROOT, DEFAULT_LEDGER, parseJsonNamed, readAnimationFlag, readJsonFile, readPackageMeta, readSkeletonText, readVersion, resolveBuild, resolveCut, resolveViewable, runCheck, spinePairOf, type BuildGate, runBuild, readProfile, STAGELESS_FRAMING, UsageError, writeJson, modelTextBeside } from './shared.ts';
23
+ import { cmdRepack } from './repack.ts';
24
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
25
+ import { dirname, join, resolve } from 'node:path';
26
+
27
+
28
+ function repositoryUrl(): string {
29
+ const repo = readPackageMeta()?.repository;
30
+ const url = typeof repo === 'string' ? repo : repo?.url;
31
+ return (url ?? 'https://github.com/firejune/rigc').replace(/^git\+/, '').replace(/\.git$/, '');
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // commands
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * `cli.ts build`'s gate (issue #1060, `BuildGate`): the round trip through
40
+ * spine-core and every named assertion — the gate `build` has always run,
41
+ * with the arguments it has always been handed, and the same lines.
42
+ */
43
+ const ROUND_TRIP_GATE: BuildGate = {
44
+ supplier: 'round-trip',
45
+ heading: (profile) => ` .. validate (spine-core round trip + machine assertions, profile ${profile})`,
46
+ run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
47
+ const report = validate({
48
+ skeletonText: result.skeletonText,
49
+ atlasText,
50
+ atlasDir,
51
+ declaredDurations: result.declaredDurations,
52
+ modelText,
53
+ reEmit,
54
+ rig: result.rig,
55
+ profile,
56
+ });
57
+ for (const line of reportLines(report)) console.log(line);
58
+ console.log(
59
+ ` .. ${Object.entries(report.stats)
60
+ .map(([k, v]) => `${k}=${v}`)
61
+ .join(' ')}`,
62
+ );
63
+ return { report, here: null };
64
+ },
65
+ look: (outDir) => `rigc: look at it: rigc preview --candidate ${outDir}`,
66
+ };
67
+
68
+ export function cmdBuild(flags: Record<string, string>): void {
69
+ runBuild(flags, ROUND_TRIP_GATE);
70
+ }
71
+
72
+ export function cmdValidate(flags: Record<string, string>, positional: string[]): void {
73
+ // A bare directory validates what is on disk. Naming the cut as well lets the
74
+ // gate re-derive the declared durations and the structural expectations, which
75
+ // a directory alone cannot supply — and the report says which it had.
76
+ const named = flags.cut !== undefined || flags.rig !== undefined;
77
+ const profile = readProfile(flags);
78
+ const derivedOpts = named ? resolveCut(flags).opts : null;
79
+ // Both files there before the first line, from the one statement of a build (issue #1046): a directory without them
80
+ // read the skeleton unguarded and died on an ENOENT and a stack.
81
+ const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas));
82
+ console.log(`rigc validate ${skeletonPath}`);
83
+ console.log(` .. atlas ${atlasPath}`);
84
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
85
+ const atlasText = readFileSync(atlasPath, 'utf8');
86
+ const derived = derivedOpts ? compile(derivedOpts) : null;
87
+
88
+ const modelText = modelTextBeside(skeletonPath, skeletonText);
89
+ const report = validate({
90
+ skeletonText,
91
+ atlasText,
92
+ atlasDir: dirname(atlasPath),
93
+ declaredDurations: derived?.declaredDurations,
94
+ rig: derived?.rig,
95
+ ...(modelText === undefined ? {} : { modelText }),
96
+ profile,
97
+ });
98
+ for (const line of reportLines(report)) console.log(line);
99
+ if (report.failures.length > 0) {
100
+ console.error(`rigc: ${report.failures.length} assertion(s) failed`);
101
+ process.exit(1);
102
+ }
103
+ console.log('rigc: green');
104
+ }
105
+
106
+ /**
107
+ * Does this skeleton declare a setup stage — a numeric width AND height?
108
+ *
109
+ * Read off the file's header for `preview`, which never loads one; `render`
110
+ * reads the same statement off its candidate (`SkeletonFacts.declaresStage`:
111
+ * the header again on a rigc build the core poses, the loaded `SkeletonData`
112
+ * on an export). Both are the same two fields, because
113
+ * `SkeletonJson` copies them across unconditionally (`SkeletonJson.js:70-73`),
114
+ * so a header that omits them leaves `undefined` on a field typed `number`.
115
+ */
116
+ function declaresSetupStage(header: { width?: unknown; height?: unknown }): boolean {
117
+ return typeof header.width === 'number' && typeof header.height === 'number';
118
+ }
119
+
120
+ /** The `skeleton` block of a skeleton file's text, or an empty one where it has none. */
121
+ function skeletonHeaderOf(skeletonText: string): { width?: unknown; height?: unknown } {
122
+ const root = JSON.parse(skeletonText) as { skeleton?: { width?: unknown; height?: unknown } };
123
+ return root.skeleton ?? {};
124
+ }
125
+
126
+ /** The animation names an emitted skeleton carries, in the order it lists them. */
127
+ function skeletonAnimationNames(skeletonText: string, path: string): string[] {
128
+ const parsed = parseJsonNamed(skeletonText, path);
129
+ if (typeof parsed !== 'object' || parsed === null) throw new UsageError(`${path} is not a skeleton object`);
130
+ const animations = (parsed as { animations?: unknown }).animations;
131
+ if (animations === undefined) return [];
132
+ if (typeof animations !== 'object' || animations === null || Array.isArray(animations)) {
133
+ throw new UsageError(`${path} has an "animations" field that is not an object`);
134
+ }
135
+ return Object.keys(animations);
136
+ }
137
+
138
+ /**
139
+ * The gate's reading of one candidate, taken the way `rigc validate <dir>`
140
+ * takes it (issue #837).
141
+ *
142
+ * ⭐ The same call `cmdValidate` makes on a bare directory: the two texts, the
143
+ * atlas's own directory, the default profile, the build's model document
144
+ * where the one beside the skeleton is its own (`modelTextBeside`, issue #907
145
+ * — the stage A14 and A19 read), and nothing a directory cannot supply — no
146
+ * rig spec, no declared durations, no second compile. So `A09` and
147
+ * `A18` report SKIP here exactly as they do there, and the line is the line
148
+ * that command prints for these files, not the one `build` printed for the
149
+ * compile that wrote them. Measured on every run, because the page must not
150
+ * carry a figure this run did not measure.
151
+ */
152
+ function previewGate(skeletonPath: string, skeletonText: string, atlasText: string, atlasDir: string): PreviewGate {
153
+ const modelText = modelTextBeside(skeletonPath, skeletonText);
154
+ const lines = reportLines(validate({ skeletonText, atlasText, atlasDir, ...(modelText === undefined ? {} : { modelText }), profile: CLI_DEFAULT_PROFILE }));
155
+ const refusal = lines.find((line) => line.startsWith(' FAIL '));
156
+ return {
157
+ // `reportLines` ends on the summary by construction; the gutter is the
158
+ // report's layout, not part of what the gate said.
159
+ summary: lines[lines.length - 1].replace(/^ {2}\.\. {4}/, ''),
160
+ refusal: refusal === undefined ? null : refusal.trimStart(),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * preview — the artifact playing in Esoteric's own web player, as one file.
166
+ *
167
+ * ⚠️ Nothing is rasterised here and nothing is decoded. The pages go into the
168
+ * page as the bytes they are on disk, so a preview works for any PNG a BROWSER
169
+ * can draw rather than for the ones our own decoder reads — which is the right
170
+ * direction for the command whose whole job is "just show me".
171
+ */
172
+ export function cmdPreview(flags: Record<string, string>, candidates: string[]): void {
173
+ // Refused rather than ignored: a preview asked to hide `head` that plays the
174
+ // whole rig is a picture that answers a question it was not asked (issue #835).
175
+ for (const flag of ['slot', 'hide'] as const) {
176
+ if (flags[flag] !== undefined) {
177
+ throw new UsageError(
178
+ `preview takes no --${flag}: the Spine Web Player draws what the skeleton draws. A subset of the slots is ` +
179
+ `\`rigc render --${flag} ${flags[flag]}\`, on the whole rig's grid`,
180
+ );
181
+ }
182
+ }
183
+ const several = candidates.length > 1;
184
+ // `--atlas` names ONE atlas, and with several skeletons there is no
185
+ // unambiguous thing it could mean — the refusal `vote` makes, for the reason
186
+ // it makes it.
187
+ if (several && flags.atlas !== undefined) {
188
+ throw new UsageError(
189
+ `--atlas names one atlas and ${candidates.length} --candidate were given; each candidate's atlas has to sit ` +
190
+ 'beside its skeleton, which is what `build --out` leaves behind',
191
+ );
192
+ }
193
+ const found = several
194
+ ? candidates.map((target) => {
195
+ const { skeletonPath, atlasPath, atlasDir } = spinePairOf(resolveBuild(target, undefined));
196
+ return { target, skeletonPath, atlasPath, atlasDir };
197
+ })
198
+ : [{ target: flags.candidate, ...resolveViewable(flags) }];
199
+ // ⚠️ By the FILE each one resolves to, not by the text typed: `build/` and
200
+ // `build/skeleton.json` are two spellings of one candidate, and so are
201
+ // `/tmp/x` and `/private/tmp/x` on a machine where one is a link to the other
202
+ // — `resolve` alone left that pair unrefused, measured on macOS. A page
203
+ // showing one skeleton twice is two panes that look like a comparison of
204
+ // nothing. (`vote` accepts a repeat today, exit 0; that is its own card.)
205
+ const identities = found.map((f) => realpathSync(f.skeletonPath));
206
+ for (let i = 1; i < found.length; i++) {
207
+ const first = identities.indexOf(identities[i]);
208
+ if (first < i) {
209
+ throw new UsageError(
210
+ `--candidate ${JSON.stringify(found[i].target)} is ${identities[i]}, which --candidate ` +
211
+ `${JSON.stringify(found[first].target)} already names (candidates ${first + 1} and ${i + 1}); a pane per ` +
212
+ 'candidate would show the same skeleton twice',
213
+ );
214
+ }
215
+ }
216
+
217
+ // Every candidate's texts and animation are read before a line is printed,
218
+ // so a refusal about any of them comes before the report, as it always has.
219
+ const loaded = found.map((f, i) => {
220
+ const skeletonText = readFileSync(f.skeletonPath, 'utf8');
221
+ const atlasText = readFileSync(f.atlasPath, 'utf8');
222
+ const animations = skeletonAnimationNames(skeletonText, f.skeletonPath);
223
+ let chosen: string | undefined;
224
+ try {
225
+ chosen = readAnimationFlag(flags, animations);
226
+ } catch (err) {
227
+ // With several candidates the refusal has to say WHICH one lacks it.
228
+ if (several && err instanceof UsageError) {
229
+ throw new UsageError(`candidate ${i + 1} (${f.skeletonPath}): ${err.message}`);
230
+ }
231
+ throw err;
232
+ }
233
+ return { ...f, skeletonText, atlasText, animations, chosen };
234
+ });
235
+
236
+ // A directory for --out is taken as "put the default name in here", because
237
+ // `--out render/` is what the sibling command means by the same flag and a
238
+ // preview written OVER a directory is not a recoverable mistake.
239
+ const target = resolve(flags.out ?? 'preview.html');
240
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
241
+ const version = readVersion();
242
+
243
+ console.log('rigc preview');
244
+ const inputs: PreviewInput[] = loaded.map((candidate, i) => {
245
+ const { skeletonPath, atlasPath, atlasDir, skeletonText, atlasText, animations, chosen } = candidate;
246
+ if (several) console.log(` .. pane ${i + 1} of ${loaded.length}`);
247
+ console.log(` .. skeleton ${skeletonPath}`);
248
+ console.log(` .. atlas ${atlasPath}`);
249
+ const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
250
+ const path = join(atlasDir, name);
251
+ if (!existsSync(path)) {
252
+ throw new UsageError(
253
+ `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
254
+ 'a page a preview cannot embed is a page the player could not have loaded either',
255
+ );
256
+ }
257
+ return { name, bytes: readFileSync(path) };
258
+ });
259
+ for (const page of pages) {
260
+ console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
261
+ }
262
+ if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
263
+ const gate = previewGate(skeletonPath, skeletonText, atlasText, atlasDir);
264
+ console.log(` .. gate ${gate.summary}`);
265
+ if (gate.refusal !== null) {
266
+ console.log(
267
+ ` .. gate refused — ${gate.refusal}. Previewed anyway: looking at a red build is what preview is ` +
268
+ 'for, and the page header says the same',
269
+ );
270
+ }
271
+ return {
272
+ skeletonText,
273
+ atlasText,
274
+ pages,
275
+ animation: chosen ?? animations[0] ?? null,
276
+ animations,
277
+ label: skeletonPath,
278
+ version,
279
+ gate,
280
+ };
281
+ });
282
+
283
+ const html = several ? buildPreviewPanes(inputs, version) : buildPreview(inputs[0]);
284
+ mkdirSync(dirname(out), { recursive: true });
285
+ writeFileSync(out, html);
286
+ const pageCount = inputs.reduce((n, input) => n + input.pages.length, 0);
287
+ console.log(
288
+ several
289
+ ? ` .. embedded ${pageCount} page(s) + ${inputs.length} skeletons and atlases as data URIs, one pane each; ` +
290
+ `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`
291
+ : ` .. embedded ${pageCount} page(s) + the skeleton and atlas as data URIs; ` +
292
+ `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
293
+ );
294
+ console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
295
+ }
296
+
297
+ /** Load one candidate off disk in the shape a ballot needs. */
298
+ function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
299
+ const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(target, undefined));
300
+ const skeletonText = readFileSync(skeletonPath, 'utf8');
301
+ const atlasText = readFileSync(atlasPath, 'utf8');
302
+ const atlasDir = dirname(atlasPath);
303
+ const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
304
+ const path = join(atlasDir, name);
305
+ if (!existsSync(path)) {
306
+ throw new UsageError(
307
+ `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
308
+ 'a page a ballot cannot embed is a page the player could not have loaded either',
309
+ );
310
+ }
311
+ return { name, bytes: readFileSync(path) };
312
+ });
313
+ return {
314
+ candidate: { source: skeletonPath, skeletonText, atlasText, pages },
315
+ animations: skeletonAnimationNames(skeletonText, skeletonPath),
316
+ };
317
+ }
318
+
319
+ /**
320
+ * The one animation every candidate plays.
321
+ *
322
+ * ⚠️ Refused rather than resolved per candidate. Two panes running two
323
+ * different animations look like a comparison and are not one, and a voter has
324
+ * no way to see that it happened — the labels are `A` and `B`, which is the
325
+ * whole point, so nothing on the screen would say so.
326
+ */
327
+ function commonAnimation(
328
+ flags: Record<string, string>,
329
+ loaded: { animations: string[] }[],
330
+ ): string | null {
331
+ const asked = flags.animation;
332
+ if (asked === undefined) {
333
+ const first = loaded[0].animations[0];
334
+ if (first === undefined) {
335
+ const withAny = loaded.findIndex((l) => l.animations.length > 0);
336
+ if (withAny !== -1) {
337
+ throw new UsageError(
338
+ `candidate ${withAny + 1} has animations [${loaded[withAny].animations.join(', ')}] and candidate 1 has none — ` +
339
+ 'a ballot plays one animation in every pane, so there is nothing to compare here',
340
+ );
341
+ }
342
+ return null;
343
+ }
344
+ const missing = loaded.findIndex((l) => !l.animations.includes(first));
345
+ if (missing !== -1) {
346
+ throw new UsageError(
347
+ `the default animation is candidate 1's first, ${JSON.stringify(first)}, and candidate ${missing + 1} does not ` +
348
+ `have it (it has [${loaded[missing].animations.join(', ') || 'none'}]); name one they share with --animation`,
349
+ );
350
+ }
351
+ return first;
352
+ }
353
+ const missing = loaded.findIndex((l) => !l.animations.includes(asked));
354
+ if (missing !== -1) {
355
+ throw new UsageError(
356
+ `no animation ${JSON.stringify(asked)} in candidate ${missing + 1}; it has ` +
357
+ `[${loaded[missing].animations.join(', ') || 'none'}]`,
358
+ );
359
+ }
360
+ return asked;
361
+ }
362
+
363
+ /** vote (ballot mode) — write the page a human opens. */
364
+ function cmdVoteBallot(flags: Record<string, string>, candidates: string[]): void {
365
+ if (candidates.length < MIN_CANDIDATES) {
366
+ throw new UsageError(
367
+ `a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} --candidate <dir | skeleton.json>, and ${candidates.length} ` +
368
+ 'was given — one candidate on its own is `rigc preview`',
369
+ );
370
+ }
371
+ if (candidates.length > MAX_CANDIDATES) {
372
+ throw new UsageError(
373
+ `${candidates.length} candidates were given and a ballot holds at most ${MAX_CANDIDATES} — they go side by side ` +
374
+ 'on one screen, and a comparison that needs scrolling is not a comparison',
375
+ );
376
+ }
377
+ // `--atlas` names ONE atlas and there are several skeletons here, so there is
378
+ // no unambiguous thing it could mean. Each candidate's atlas has to sit beside
379
+ // its skeleton, which is what `build --out` leaves behind.
380
+ if (flags.atlas !== undefined) {
381
+ throw new UsageError(
382
+ '--atlas names one atlas and a ballot has several candidates; each one\'s atlas has to sit beside its skeleton',
383
+ );
384
+ }
385
+
386
+ const loaded = candidates.map((target) => loadBallotCandidate(target));
387
+ const animation = commonAnimation(flags, loaded);
388
+
389
+ const target = resolve(flags.out ?? DEFAULT_BALLOT);
390
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_BALLOT) : target;
391
+
392
+ const input: BallotInput = {
393
+ candidates: loaded.map((l) => l.candidate),
394
+ animation,
395
+ version: readVersion(),
396
+ };
397
+ const { html, manifest } = buildBallot(input);
398
+
399
+ console.log('rigc vote');
400
+ console.log(` .. ballot ${manifest.ballot}`);
401
+ console.log(` .. animation ${animation === null ? '(none — the setup pose)' : animation}`);
402
+ for (let i = 0; i < manifest.candidates.length; i++) {
403
+ const entry = manifest.candidates[i];
404
+ const bytes = loaded[i].candidate.pages.reduce((n, p) => n + p.bytes.length, 0);
405
+ console.log(
406
+ ` .. ${entry.label} ${entry.digest.slice(0, 'sha256:'.length + 12)}… ` +
407
+ `${entry.pages.length} page(s), ${(bytes / 1024).toFixed(1)} KiB <- ${entry.source}`,
408
+ );
409
+ }
410
+ mkdirSync(dirname(out), { recursive: true });
411
+ writeFileSync(out, html);
412
+ console.log(
413
+ ` .. the page shows ${manifest.candidates.map((c) => c.label).join('/')} and nothing else — the paths above are ` +
414
+ 'in its manifest, never on the screen',
415
+ );
416
+ console.log(
417
+ ` .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from ` +
418
+ `unpkg (@${PLAYER_LINE}), so the first open needs a network`,
419
+ );
420
+ console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
421
+ console.log(
422
+ `rigc: then record the saved vote with rigc vote --record ${resultFilename(manifest.ballot)} --ballot ${out}`,
423
+ );
424
+ }
425
+
426
+ /** vote (record mode) — check one saved vote and append it to the ledger. */
427
+ function cmdVoteRecord(flags: Record<string, string>): void {
428
+ for (const key of ['candidate', 'out'] as const) {
429
+ if (flags[key] !== undefined) {
430
+ throw new UsageError(`--record and --${key} are the two halves of this command; run them one at a time`);
431
+ }
432
+ }
433
+ const resultPath = resolve(flags.record);
434
+ const ballotPath = resolve(flags.ballot ?? DEFAULT_BALLOT);
435
+ const ledgerPath = resolve(flags.ledger ?? DEFAULT_LEDGER);
436
+ for (const [what, path] of [
437
+ ['result', resultPath],
438
+ ['ballot', ballotPath],
439
+ ] as const) {
440
+ if (!existsSync(path)) {
441
+ throw new UsageError(
442
+ `no ${what} file at ${path}` + (what === 'ballot' ? ' — name the page this vote came from with --ballot' : ''),
443
+ );
444
+ }
445
+ }
446
+
447
+ console.log('rigc vote --record');
448
+ console.log(` .. result ${resultPath}`);
449
+ console.log(` .. ballot ${ballotPath}`);
450
+ console.log(` .. ledger ${ledgerPath}`);
451
+
452
+ const manifest = readBallotManifest(readFileSync(ballotPath, 'utf8'), ballotPath);
453
+ const result = readJsonFile(resultPath);
454
+ const existing = existsSync(ledgerPath) ? parseLedger(readFileSync(ledgerPath, 'utf8'), ledgerPath) : [];
455
+ const attempts = existing.filter((l) => l.ballot === manifest.ballot).length;
456
+ const again = flags.again !== undefined;
457
+
458
+ const { refusals, line } = verifyResult(manifest, result, { attempts, again });
459
+ if (line === null) {
460
+ for (const refusal of refusals) console.error(` FAIL ${refusal.rule}: ${refusal.detail}`);
461
+ console.error(`rigc: ${refusals.length} refusal(s) — nothing appended to ${ledgerPath}`);
462
+ process.exit(1);
463
+ }
464
+ for (const rule of VOTE_RULES) console.log(` PASS ${rule}`);
465
+
466
+ line.seq = existing.length + 1;
467
+ mkdirSync(dirname(ledgerPath), { recursive: true });
468
+ appendFileSync(ledgerPath, ledgerLineText(line));
469
+ console.log(
470
+ ` .. ${line.choice === TIE ? 'tie' : `winner ${line.choice} = ${line.winner}`}, ` +
471
+ `reason code ${line.reasonCode}${line.attempt > 1 ? `, attempt ${line.attempt}` : ''}`,
472
+ );
473
+ console.log(
474
+ ` .. coverage ${line.coverage.length} candidate(s): ` +
475
+ line.coverage.map((c) => `${c.label}=${c.digest.slice(0, 'sha256:'.length + 12)}…`).join(' '),
476
+ );
477
+ console.log(`rigc: appended line ${line.seq} to ${ledgerPath}`);
478
+ }
479
+
480
+ export function cmdVote(flags: Record<string, string>, candidates: string[]): void {
481
+ if (flags.record !== undefined) cmdVoteRecord(flags);
482
+ else if (candidates.length > 0) cmdVoteBallot(flags, candidates);
483
+ else {
484
+ throw new UsageError(
485
+ 'vote takes either 2–4 --candidate <dir | skeleton.json> to write a ballot, or --record <result.json> to ' +
486
+ 'record one that came back',
487
+ );
488
+ }
489
+ }
490
+
491
+ /**
492
+ * bench — run one rung of the benchmark ladder against a candidate rig.
493
+ *
494
+ * Two questions, asked in this order and never merged:
495
+ *
496
+ * 1. Is the candidate valid Spine at all? That is `validate --profile spine`,
497
+ * and it is the only part with a pass/fail. The profile is pinned here, not
498
+ * inherited from the CLI default: the thing being reproduced is an editor
499
+ * export, and holding it to this project's renderer policy would fail rungs
500
+ * for reasons the rung is not about.
501
+ * 2. How close is it, structurally, to the reference? That is `diff`, and it
502
+ * has no threshold at all. There is no score to pass, on purpose — see
503
+ * `src/diff.ts`. A rung is called cleared by a human reading the measures,
504
+ * and `docs/LADDER.md` records that judgement.
505
+ *
506
+ * ⚠️ The candidate is validated against the SPINE profile and compared against
507
+ * the reference; the reference is never validated here. It is editor output and
508
+ * is the definition of correct for this exercise, so gating it would be gating
509
+ * the yardstick with the ruler.
510
+ */
511
+ export function cmdBench(flags: Record<string, string>, positional: string[]): void {
512
+ const rungId = positional[0];
513
+ if (!rungId) throw new UsageError(`bench takes a rung: ${RUNG_IDS.join(' | ')}`);
514
+ const rung = findRung(rungId);
515
+ if (!rung) throw new UsageError(`unknown rung ${JSON.stringify(rungId)}; known: ${RUNG_IDS.join(', ')}`);
516
+ if (flags.candidate === undefined) throw new UsageError('bench needs --candidate <dir | skeleton.json>');
517
+
518
+ // bench judges a reproduction of editor output, so `spine` is PINNED here
519
+ // rather than inherited. It reads the same as the CLI default today (#221) and
520
+ // is kept as its own statement anyway: the ladder's stage-1 gate is defined by
521
+ // `docs/GATE.md` as `validate --profile spine`, and a bench run must go on
522
+ // meaning that whatever a later release decides the default should be.
523
+ const profile: ValidateProfile = flags.profile === undefined ? 'spine' : readProfile(flags);
524
+ const exportDir = resolve(PACKAGE_ROOT, 'examples', rung.example, 'export');
525
+ if (!existsSync(exportDir)) {
526
+ // `bun run fetch-examples` runs `scripts/fetch-examples.sh`, and `scripts/`
527
+ // is not in package.json's `files` — an npm install has no such script to
528
+ // run. Its presence is what tells the two contexts apart, so the remedy
529
+ // named here is one that actually exists in whichever context this is.
530
+ const remedy = existsSync(resolve(PACKAGE_ROOT, 'scripts', 'fetch-examples.sh'))
531
+ ? 'run `bun run fetch-examples` first'
532
+ : `bench needs a checkout of ${repositoryUrl()} — its \`fetch-examples\` script is not part of the installed package`;
533
+ throw new UsageError(`no example corpus at ${exportDir} — ${remedy} (examples/ is gitignored, not shipped)`);
534
+ }
535
+
536
+ // Both files there and the skeleton JSON before the first line is printed, refused by name as `render` refuses
537
+ // them (issue #1042): a missing file and a skeleton that is not JSON surfaced as an ENOENT or a SyntaxError and a
538
+ // stack, the second only after the validate block had printed.
539
+ const { skeletonPath, atlasPath } = resolveViewable({ candidate: flags.candidate, ...(flags.atlas === undefined ? {} : { atlas: flags.atlas }) });
540
+ const skeletonText = readSkeletonText(skeletonPath);
541
+ const atlasText = readFileSync(atlasPath, 'utf8');
542
+
543
+ console.log(`rigc bench rung ${rung.id} — ${rung.example}`);
544
+ console.log(` gates ${rung.gates}`);
545
+ console.log(` candidate ${skeletonPath}`);
546
+ console.log(` atlas ${atlasPath}`);
547
+ console.log('');
548
+
549
+ console.log(` ── validate (profile ${profile}) ──`);
550
+ const benchModel = modelTextBeside(skeletonPath, skeletonText);
551
+ const report = validate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), ...(benchModel === undefined ? {} : { modelText: benchModel }), profile });
552
+ for (const line of reportLines(report)) console.log(` ${line}`);
553
+ console.log('');
554
+
555
+ const candidateJson: unknown = JSON.parse(skeletonText);
556
+ const diffs: Array<{ skeleton: RungSkeleton; reference: string; report: DiffReport }> = [];
557
+ for (const skeleton of rung.skeletons) {
558
+ const referencePath = join(exportDir, skeleton.file);
559
+ if (!existsSync(referencePath)) {
560
+ console.error(` MISSING ${referencePath} — re-run \`bun run fetch-examples\``);
561
+ continue;
562
+ }
563
+ const role = skeleton.role === 'stretch' ? ' (stretch — reported, does not count)' : '';
564
+ console.log(` ── diff vs ${rung.example}/${skeleton.label}${role} ──`);
565
+ const diff = diffSkeletons(candidateJson, JSON.parse(readFileSync(referencePath, 'utf8')));
566
+ for (const line of diffLines(diff, { candidate: skeletonPath, reference: referencePath })) console.log(` ${line}`);
567
+ console.log('');
568
+ diffs.push({ skeleton, reference: referencePath, report: diff });
569
+ }
570
+
571
+ // Stage 3, optional and behind a flag because the correspondence is an INPUT:
572
+ // a candidate is entitled to its own bone names, so there is nothing sensible
573
+ // to default to and a derived mapping would be a guess reported as a
574
+ // measurement (issue #8). Nothing here gates, and without the flag the report
575
+ // above is unchanged to the byte.
576
+ const boneDists: Array<{ skeleton: RungSkeleton; report: BoneDistReport }> = [];
577
+ if (flags.bones !== undefined) {
578
+ for (const skeleton of rung.skeletons) {
579
+ const referencePath = join(exportDir, skeleton.file);
580
+ if (!existsSync(referencePath)) continue;
581
+ console.log(` ── bonedist vs ${rung.example}/${skeleton.label} (stage 3) ──`);
582
+ const boneDist = boneDistance({
583
+ candidateSkeleton: skeletonPath,
584
+ candidateAtlas: atlasPath,
585
+ referenceSkeleton: referencePath,
586
+ referenceAtlas: join(exportDir, skeleton.atlas),
587
+ bones: flags.bones,
588
+ // Deliberately NOT `flags.fps`. Inside `bench` that flag already means
589
+ // "the rate this frame set was recorded at, for a set with no sidecar",
590
+ // and one flag doing two unrelated things in one command is how a
591
+ // reader ends up quoting a figure measured at a rate they did not ask
592
+ // for. A run wanting another sampling rate calls `rigc bonedist`, where
593
+ // `--fps` has exactly one meaning.
594
+ });
595
+ for (const line of boneDistLines(boneDist, { allBones: flags['all-bones'] !== undefined })) console.log(` ${line}`);
596
+ console.log('');
597
+ boneDists.push({ skeleton, report: boneDist });
598
+ }
599
+ }
600
+
601
+ // Third, optional and third for a reason: is it the same MOTION? `diff`
602
+ // compares structure, and a reversed easing is the same key count and the same
603
+ // curve kind — so a row of this ladder carrying only `validate` and `diff`
604
+ // records a rig that could be animated backwards. `--frames` folds `check`'s
605
+ // table into the report so a future row carries both.
606
+ let check: CheckReport | null = null;
607
+ if (flags.frames !== undefined) {
608
+ console.log(` ── check vs frames ${resolve(flags.frames)} ──`);
609
+ check = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
610
+ for (const line of checkLines(check, { allFrames: flags['all-frames'] !== undefined })) console.log(` ${line}`);
611
+ console.log('');
612
+ }
613
+
614
+ console.log(' ── summary ──');
615
+ console.log(` validate ${report.failures.length === 0 ? 'green' : `${report.failures.length} FAILED`} (profile ${profile})`);
616
+ for (const d of diffs) {
617
+ const means = d.report.sections.map((s) => `${s.name}=${s.ratio.toFixed(3)}`).join(' ');
618
+ console.log(` ${d.skeleton.label.padEnd(10)} ${means}${d.skeleton.role === 'stretch' ? ' [stretch]' : ''}`);
619
+ // Second line, not folded into the first: the figures above are the ones
620
+ // every bench.json on disk already carries, and a ladder record is worth
621
+ // less the moment its headline stops meaning what the older ones meant.
622
+ // The sections whose measures are dominated by name-keyed ones get their
623
+ // name-agnostic figure printed beside — issue #21.
624
+ const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
625
+ if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
626
+ // A third line, for the same reason the second one is not folded into the
627
+ // first: the reported measures are unobservable by construction, so they
628
+ // roll into no mean at all and cannot be shown as one. Each is named with
629
+ // its own figure — a per-section digest would be the mean this block exists
630
+ // to refuse.
631
+ const reported = reportedFigures(d.report);
632
+ if (reported !== null) console.log(` ${''.padEnd(10)} reported: ${reported}`);
633
+ }
634
+ if (check) {
635
+ // The framing goes first because it is upstream of every MAE below it: a
636
+ // summary that reported those numbers without saying how the two shots were
637
+ // put on each other is how issue #34 stayed invisible for two ladder runs.
638
+ const framing = check.framingFit;
639
+ if (!framing && check.sharedFraming) {
640
+ const f = check.sharedFraming.fit;
641
+ console.log(
642
+ ` framing one per set (${check.animations.length}); one shared box leaves ` +
643
+ `x${f.scale.toFixed(6)}, rms ${f.rms.toFixed(2)}px — see the check table above for each set's own`,
644
+ );
645
+ }
646
+ if (framing) {
647
+ const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
648
+ const how = !framing.applied
649
+ ? 'measured only — --viewport pinned'
650
+ : framing.source === 'declared'
651
+ ? `frames.json's own box, the candidate measured into it`
652
+ : `fitted to the candidate's pixels, ${framing.passes} pass(es)${framing.settled ? '' : framing.cycled ? ', cycling' : ', unsettled'}`;
653
+ // The MAE-refined offset belongs on this line rather than only in `check`'s
654
+ // own table: it moved the box every figure below was measured in, so a row
655
+ // that quoted the figures without it would not say what they were measured
656
+ // against — issue #146's own version of the #34 lesson above.
657
+ const r = framing.refinement;
658
+ const refined =
659
+ r === null || !r.applied
660
+ ? ''
661
+ : ` MAE-refined ${signed(r.dx)}, ${signed(r.dy)}px (${r.before.toFixed(2)} → ${r.after.toFixed(2)} ref)`;
662
+ console.log(
663
+ ` framing fit x${framing.fit.scale.toFixed(6)} rms ${framing.fit.rms.toFixed(2)}px union residual ` +
664
+ `${signed(framing.fit.residualWidth)} x ${signed(framing.fit.residualHeight)}px (${how})${refined}`,
665
+ );
666
+ }
667
+ for (const anim of check.animations) {
668
+ const attributed = anim.compared - anim.framesWithoutDrift;
669
+ const drift =
670
+ anim.worstDriftFrame < 0
671
+ ? 'no slot attributable in any of them'
672
+ : `worst slot drift ${anim.worstDrift.toFixed(1)}px, attributed in ${attributed}`;
673
+ // The per-frame change count is carried here and not only in `check`'s own
674
+ // table because it is the one figure a flat MAE cannot imply: a shot can be
675
+ // right at every frame and still hold or blink at the wrong moments.
676
+ const change =
677
+ anim.changeDisagreements === 0
678
+ ? ''
679
+ : `, ${anim.changeDisagreements}/${anim.changePairs} pair(s) change unlike the reference`;
680
+ // Which of the candidate's own bone chains the error is in — one name, so a
681
+ // loop between builds reads a unit to fix rather than a verdict on the shot.
682
+ // The full table is in `check`'s own report; this is its headline.
683
+ const worstChain = [...anim.chains].sort((a, b) => b.maeShare - a.maeShare)[0];
684
+ const chain =
685
+ worstChain === undefined ? '' : `, ${worstChain.chain} carries ${(worstChain.maeShare * 100).toFixed(0)}%`;
686
+ // `ref=` is the same difference over the reference's own drawn pixels. It is
687
+ // carried here and not only in `check`'s own table because this is the line a
688
+ // loop reads between builds, and `mean=` has a denominator the candidate can
689
+ // grow — see `FrameCheck.maeReference`.
690
+ // ...and the contact sheet, when the set ships one: a row reading "over 2
691
+ // frame(s)" for a 311-frame shot is the hole issue #36 closed, and the whole
692
+ // -shot figure is the one that says the frames between the stills were seen.
693
+ const sheet =
694
+ anim.sheet === null
695
+ ? ''
696
+ : `, sheet ${anim.sheet.compared} tile(s) mean=${anim.sheet.meanMae.toFixed(2)} ` +
697
+ `worst=${anim.sheet.worstMae.toFixed(2)}`;
698
+ console.log(
699
+ ` ${anim.dir.padEnd(10)} MAE mean=${anim.meanMae.toFixed(2)} worst=${anim.worstMae.toFixed(2)} ` +
700
+ `ref=${anim.meanMaeReference.toFixed(2)} over ${anim.compared} frame(s) ${drift}${change}${chain}${sheet}`,
701
+ );
702
+ }
703
+ } else {
704
+ console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
705
+ console.log(' Without it this report says nothing about whether the ANIMATION is right.');
706
+ }
707
+ if (boneDists.length > 0) {
708
+ for (const b of boneDists) {
709
+ const w = b.report.worst;
710
+ console.log(
711
+ ` ${b.skeleton.label.padEnd(10)} bonedist worst position ${w.position.value.toFixed(6)} skeleton-size(s), ` +
712
+ `rotation ${w.rotation.value.toFixed(4)}°, scale ${w.scale.value.toFixed(6)}, linear ${w.linear.value.toFixed(6)} ` +
713
+ `over ${b.report.animations.reduce((n, a) => n + a.compared, 0)} frame(s) × ${b.report.correspondence.pairs} bone pair(s)`,
714
+ );
715
+ }
716
+ } else {
717
+ console.log(' bonedist not run — pass --bones <correspondence.json | identity> for the stage-3 per-frame');
718
+ console.log(' bone world-transform distance. It reports and gates nothing.');
719
+ }
720
+ console.log(' Section figures are means of their own measures. There is no rung score:');
721
+ console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
722
+
723
+ if (flags.json !== undefined) {
724
+ // No `gates` field, deliberately. The rung's gate string names its features
725
+ // and its per-skeleton counts, which `bench/runs/README.md` forbids a run
726
+ // from reading — and this report is one of the six files the run protocol
727
+ // requires committing, so a copy of it here would sit inside every future
728
+ // run's directory, which is exactly where the next author looks for process
729
+ // notes. `rung` identifies the rung and carries nothing (issue #137). The
730
+ // console block above still prints the gate string: that is for the person
731
+ // reading the run, not a file the protocol commits.
732
+ writeJson(flags.json, {
733
+ rung: rung.id,
734
+ example: rung.example,
735
+ profile,
736
+ candidate: { skeleton: skeletonPath, atlas: atlasPath },
737
+ validate: report,
738
+ // `referencePath`, not `reference`: a DiffReport already has a
739
+ // `reference` of its own (the raw counts), and the spread wins.
740
+ diffs: diffs.map((d) => ({
741
+ label: d.skeleton.label,
742
+ role: d.skeleton.role,
743
+ referencePath: d.reference,
744
+ ...d.report,
745
+ })),
746
+ check,
747
+ // Absent rather than null when the flag was not passed: `bonedist: null`
748
+ // in a stored record would read as "measured, nothing to report", and
749
+ // that is the opposite of "not measured".
750
+ ...(boneDists.length === 0
751
+ ? {}
752
+ : { boneDists: boneDists.map((b) => ({ label: b.skeleton.label, role: b.skeleton.role, ...b.report })) }),
753
+ });
754
+ }
755
+
756
+ if (report.failures.length > 0) {
757
+ console.error(`rigc: candidate is not valid Spine — ${report.failures.length} assertion(s) failed`);
758
+ process.exit(1);
759
+ }
760
+ }
761
+
762
+ /**
763
+ * bonedist — the ladder's stage 3, run on its own.
764
+ *
765
+ * ⚠️ It reads BOTH skeletons, so it is a finish-line instrument like `bench` and
766
+ * unlike `check`. Every convention behind every figure is printed above the
767
+ * tables, and there is no score — see [`src/bonedist.ts`](src/bonedist.ts).
768
+ */
769
+ export function cmdBoneDist(flags: Record<string, string>): void {
770
+ if (flags.candidate === undefined) throw new UsageError('bonedist needs --candidate <dir | skeleton.json>');
771
+ if (flags.reference === undefined) throw new UsageError('bonedist needs --reference <skeleton.json>');
772
+ if (flags.bones === undefined) {
773
+ throw new UsageError(
774
+ `bonedist needs --bones <correspondence.json | ${IDENTITY_CORRESPONDENCE}> — a candidate is entitled to its own bone ` +
775
+ 'names, so the mapping is an input and never a guess; pass `identity` to state that the two use the same names',
776
+ );
777
+ }
778
+ // Each side's files there and its skeleton JSON, refused by name as `render` refuses them (issue #1042); a pair
779
+ // that does not load is `boneDistance`'s refusal (`CandidatePairError`, exit 2 below).
780
+ const sideOf = (target: string, atlas: string | undefined): { skeletonPath: string; atlasPath: string } => {
781
+ const side = resolveViewable({ candidate: target, ...(atlas === undefined ? {} : { atlas }) });
782
+ readSkeletonText(side.skeletonPath);
783
+ return side;
784
+ };
785
+ const candidate = sideOf(flags.candidate, flags.atlas);
786
+ const reference = sideOf(flags.reference, flags['reference-atlas']);
787
+ const report = boneDistance({
788
+ candidateSkeleton: candidate.skeletonPath,
789
+ candidateAtlas: candidate.atlasPath,
790
+ referenceSkeleton: reference.skeletonPath,
791
+ referenceAtlas: reference.atlasPath,
792
+ bones: flags.bones,
793
+ ...(flags.fps === undefined ? {} : { fps: Number(flags.fps) }),
794
+ });
795
+ console.log('rigc bonedist — per-frame bone world-transform distance (the ladder\'s stage 3)');
796
+ for (const line of boneDistLines(report, { allBones: flags['all-bones'] !== undefined })) console.log(line);
797
+ if (flags.json !== undefined) writeJson(flags.json, report);
798
+ }
799
+
800
+ /**
801
+ * The bodies of every command whose `runtime` is not `false` (`COMMANDS`), by
802
+ * name — what only `cli.ts` registers, beside `./core_commands.ts`'s.
803
+ */
804
+ export const SPINE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
805
+ build: ({ flags }) => cmdBuild(flags),
806
+ repack: (args) => cmdRepack(args, ROUND_TRIP_GATE),
807
+ validate: ({ flags, positional }) => cmdValidate(flags, positional),
808
+ bench: ({ flags, positional }) => cmdBench(flags, positional),
809
+ bonedist: ({ flags }) => cmdBoneDist(flags),
810
+ preview: ({ flags, lists }) => cmdPreview(flags, lists.candidate ?? []),
811
+ vote: ({ flags, lists }) => cmdVote(flags, lists.candidate ?? []),
812
+ };
813
+
814
+ /** `bonedist`'s refusal, whose class lives in the module that poses through spine-core (`CliEntry.refusals`). */
815
+ export const SPINE_COMMAND_REFUSALS: readonly CliRefusal[] = [
816
+ { is: (err) => err instanceof BoneDistError, prefix: 'rigc bonedist error: ', status: 1 },
817
+ ];
818
+
819
+ /** Each profile's rule count — the validator's, which the usage's `--profile` paragraph states. */
820
+ export const PROFILE_RULES = (profile: 'spine' | 'spine-html'): number => assertionCountForProfile(profile);