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,1627 @@
1
+ /**
2
+ * The bodies of the commands that reach nothing of spine-core — `explain`,
3
+ * `ingest`, `diff`, `check`, `render`, `pose`, `chainfit` and `skills` —
4
+ * moved here unchanged from `cli.ts` (issue #1052, step 4e of #380), with the
5
+ * helpers only they call. Both entries register them (`CORE_COMMAND_RUNS`):
6
+ * `cli.ts` beside the runtime's, `cli_core.ts` alone. And the body
7
+ * `cli_core.ts` runs as `build` (`CORE_ENTRY_RUNS`, issue #1060): `runBuild`
8
+ * with a gate that links none of the runtime.
9
+ *
10
+ * Which commands these are is not a list kept here: it is `COMMANDS`'s
11
+ * `runtime` field (`./shared.ts`), which `RC26` in `selftest.ts` holds to the
12
+ * import graph of this module. A body here that reaches the runtime turns it
13
+ * red naming the chain, and `cli_core.ts` would stop linking with the package
14
+ * absent, which `RC24` runs.
15
+ */
16
+ import { parseAtlasText } from '../atlas.ts';
17
+ import { validateEmittedText } from '../assertions/emitted/index.ts';
18
+ import { validateModel } from '../assertions/model/index.ts';
19
+ import { reportLines } from '../assertions/report.ts';
20
+ import { chainFitLines, type ChainFitOptions, estimateChainFit } from '../chainfit.ts';
21
+ import { checkLines, CheckPlates } from '../check.ts';
22
+ import { writeCheckPictures } from '../checkpics.ts';
23
+ import { compile, type CompileOptions, droppedStateReason, relativeImagesPath } from '../compile.ts';
24
+ import { CoreInputError } from '../core/index.ts';
25
+ import { surveyOfBuild } from '../deformbuild.ts';
26
+ import type { DeformSurvey } from '../deformsurvey.ts';
27
+ import { deformReportBlock } from '../deformreport.ts';
28
+ import { type DiffAnimationPair, diffLines, diffSkeletons } from '../diff.ts';
29
+ import { ingest, INGEST_GUTTERS, type IngestFinding, IngestSpecRefused, type IngestStage } from '../ingest.ts';
30
+ import { PARSER_DEFAULTS, parserReading } from '../keyorder.ts';
31
+ import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
32
+ import { parseMotionSpec } from '../motion.ts';
33
+ import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
34
+ import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
35
+ import { type CompileResult } from '../types.ts';
36
+ import { attachmentRegionJoins } from '../region_joins.ts';
37
+ import { cmdRepack } from './repack.ts';
38
+ import { ATLAS_ABSENT, COMMANDS, type CommandRun, resolveBuild, type BuildGate, runBuild, PACKAGE_ROOT, DEFAULT_CHAINFIT_OUT, DEFAULT_POSE_OUT, DEFAULT_SKILLS_DIR, ExplainError, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, PAGE_GRID_UNLOCATED, readAnimationFlag, readJsonFile, readPoserFlag, readSkeletonText, readVersion, resolveCut, resolveDrawable, runCheck, SkillsInstallError, STAGELESS_FRAMING, UsageError, writeJson, documentStageBeside } from './shared.ts';
39
+ import { cpSync, existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from 'node:fs';
40
+ import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
41
+
42
+
43
+ const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> = {
44
+ ring: 'ring rim ring pinned on the window edge, seam ring pinned on the mask contour, aperture moves',
45
+ ribbon: 'ribbon entry row pinned, rows share their weights so the strip lengthens without widening',
46
+ contour: 'contour the art\'s own silhouette, every vertex pinned to the slot bone (geometry, not a deformation)',
47
+ grid: 'grid a lattice over the part window at stated column and row positions, every vertex pinned to the slot bone',
48
+ segments: 'segments a lattice over the part\'s alpha, every vertex weighted by distance to the bone segments it names',
49
+ authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
50
+ };
51
+
52
+ /**
53
+ * The `MEMBER` report block — a group track's per-member values, side by side
54
+ * (issue #295).
55
+ *
56
+ * ## Why side by side is the whole point
57
+ *
58
+ * The complaint that filed #295 was not the line count. `gallery/portrait`'s
59
+ * held yaw put six sibling bones' `translatex` in six separate tracks, and the
60
+ * reason that is bad is that **nobody can see a wrong sign in a column that is
61
+ * eighty lines from its neighbours.** FACE §3 makes the same argument from the
62
+ * other side: a residual is 1–6 units where a total is 30–40, and the split is
63
+ * *an auditing decision before it is a rigging one*. So the report's job is to
64
+ * put the numbers in the arrangement the audit needs — one row per member, one
65
+ * block per key — which is exactly the arrangement the emitted format cannot
66
+ * have, because Spine keys one bone per timeline.
67
+ *
68
+ * ## It quotes; it does not re-derive
69
+ *
70
+ * The same rule as the `DEFORM` block. Every value here is the one the compiler
71
+ * **emitted**, carried on `result.trackDerivations`, so the block and the
72
+ * artifact cannot disagree. `derived` and `formula` are the model's own strings
73
+ * from `src/trackgen.ts`, so the block names the closed form the spec stated
74
+ * rather than a second reading of it.
75
+ *
76
+ * ## What it deliberately does not print
77
+ *
78
+ * **Tracks whose members all share one value** — the ordinary `groups` entry.
79
+ * There is one number there and the timelines above already show it on every
80
+ * member; a table of six identical rows would be a tautology, and the block
81
+ * exists to make a *difference* visible. `look_l`/`look_r` in the worked example
82
+ * are exactly that case and they are right to be absent from here.
83
+ *
84
+ * **`stagger`.** A per-member time offset is printed as it always was — on each
85
+ * member's own timeline, where the shifted key times are. Repeating it here
86
+ * would put one lag in two places.
87
+ */
88
+ function memberReportLines(result: CompileResult): string[] {
89
+ if (result.trackDerivations.length === 0) return [];
90
+ const out: string[] = [
91
+ '',
92
+ 'group members (the per-member values of one track, side by side — issue #295)',
93
+ ' .. a row per member and a block per key, because a wrong sign is visible in a column of six and',
94
+ ' .. invisible in six tracks. Values are the EMITTED ones, so this and the artifact cannot disagree',
95
+ ' .. a group whose members all share one value is not here: there is one number and the timelines',
96
+ ' .. above already carry it. `stagger` is not here either — the shifted key times are on those timelines',
97
+ ];
98
+ for (const entry of result.trackDerivations) {
99
+ const states =
100
+ entry.model === null
101
+ ? 'stated per member'
102
+ : `derive ${entry.model.kind} ${entry.model.stated} -> ${entry.model.projection === 'shift' ? 'the displacement' : 'the narrowing'}`;
103
+ out.push(
104
+ ` MEMBER ${entry.animation} ${entry.targetKind} "${entry.target}".${entry.property} ` +
105
+ `t=${entry.time.toFixed(6)} ${entry.members.length} member(s) ${states}`,
106
+ );
107
+ if (entry.model !== null) {
108
+ out.push(` ${entry.model.formula}`);
109
+ for (const line of entry.model.derived) out.push(` ${line}`);
110
+ }
111
+ const width = Math.max(6, ...entry.members.map((m) => m.member.length));
112
+ for (let i = 0; i < entry.members.length; i++) {
113
+ const m = entry.members[i];
114
+ const value = Array.isArray(m.value) ? m.value.join(', ') : JSON.stringify(m.value);
115
+ // The model's own row carries the two inputs that produced the value — the
116
+ // coordinate it read off the rig and the depth the spec stated — because
117
+ // "5.513" alone is a number a reader can only take on trust, and `−62` and
118
+ // `150` beside it are a claim they can check.
119
+ const from = entry.model === null ? '' : ` <- ${entry.model.members[i].at >= 0 ? ' ' : ''}${entry.model.members[i].at} at depth ${entry.model.members[i].depth}`;
120
+ out.push(` ${m.member.padEnd(width)} ${value.padStart(12)}${from}`);
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * The `DEFORM` report block — what each deform key does to the geometry, per key
128
+ * and then per animation (issue #316).
129
+ *
130
+ * ## Why this is a report and not an assertion
131
+ *
132
+ * Because a 3× stretch is a real thing to author, for the same reason issue #277
133
+ * settled mesh coverage as a printed figure on authored geometry rather than a
134
+ * bar. The one deformed-geometry fault that has no legitimate counter-example is
135
+ * the fold, and that one already IS an assertion —
136
+ * `A39_DEFORM_KEEPS_TRIANGLE_WINDING`. What this block adds is **the approach to
137
+ * that wall**: FACE §4.2's table of ratios down to the fold at 31.37° was
138
+ * measured by rendering seven variants of `gallery/portrait` and looking at them,
139
+ * and `0.637` was a number an author derived from the closed form rather than one
140
+ * the tool printed.
141
+ *
142
+ * ## It quotes; it does not re-derive
143
+ *
144
+ * - the reversal and collapse counts are the **survey's**, which is A39's own
145
+ * survey ([`src/deformmeasure.ts`](src/deformmeasure.ts)) — one measurement,
146
+ * two readers, so the block and the gate cannot disagree about a fold;
147
+ * - 🔒 and so is **the frame each key was posed in** (issue #407), which every
148
+ * `DEFORM` line now names: `on a track`, or the slider that applies the
149
+ * animation and the dial value its own mapping had to be inverted to. The
150
+ * derivation moved and the report had to move with it — a block that went on
151
+ * printing the same figures under a changed meaning would be worse than the
152
+ * red it replaced;
153
+ * - a key's model is the **compiler's** `transform` report (§4.11.1), so the
154
+ * block names the same `kind` and parameters the spec stated;
155
+ * - the fold ANGLE is nowhere here. It is A39's, derived at run time from the
156
+ * grid, and a second copy of it printed beside a ratio would be a number that
157
+ * goes stale when somebody moves a column;
158
+ * - and a key the gate read **no winding** off — because the slot draws no pixels
159
+ * of the mesh at that key's own time (issue #401) — says so on a `skipped` line
160
+ * with the survey's own sentence, and is kept out of the rollup's counts,
161
+ * because that line ends by claiming A39 reads the same two;
162
+ * - the **spans** between the keys are the survey's too (issue #403). A `BETWEEN`
163
+ * line appears wherever the closed form found a fold at a time no key lands
164
+ * on, whether the gate refuses it or passes it over because nothing is drawn
165
+ * there — and a `spans` line says how many were scanned even when nothing was
166
+ * found, because a scan that ran and found nothing has to be distinguishable
167
+ * from a scan that never ran.
168
+ *
169
+ * ## And what it deliberately does not print
170
+ *
171
+ * **Deformed coverage**, which #296 asked for. The coverage figure is rasterised
172
+ * from the attachment's **uvs** against the part's alpha, and a deform moves
173
+ * positions and never uvs — so it is identical at every key by construction, and
174
+ * a `coverage 100.00% (setup 100.00%)` line would be a tautology wearing a
175
+ * measurement's clothes. The header line says so and points at `meshes`, because
176
+ * an author who came here asking whether their deform broke the coverage
177
+ * deserves the answer rather than a silence. What does move is the stretch.
178
+ */
179
+ function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>, poser: PoserName | undefined, label: string): string[] {
180
+ // Read and posed through rigc's own core over the model document the build carries (issues #969, #1019) —
181
+ // spine-core is not touched — and through spine-core under `--poser spine` or when the core refuses the
182
+ // document, which the block then names; `tools/survey_hashes.ts` holds the two to one block. The block
183
+ // itself is `deformReportBlock` (`src/deformreport.ts`), so a control renders it off either reader's survey.
184
+ const input = { skeletonText: result.skeletonText, atlasText: result.atlasText, modelText: modelDocument(result.model, result.skeletonText, result.atlasText), label: `the deform survey of ${label}` };
185
+ let survey: DeformSurvey;
186
+ try {
187
+ survey = surveyOfBuild(input, new Set(), poser === undefined ? 'auto' : poser === 'core' ? 'model' : 'spine-core');
188
+ } catch (err) {
189
+ // `--poser core` on a build the core refuses: a refusal of the invocation, as `render` and `check` give it.
190
+ if (poser === 'core' && err instanceof CoreInputError) throw new ExplainError(`--poser core: the core refused to pose the deform survey's model document — ${err.message}`);
191
+ throw err;
192
+ }
193
+ return deformReportBlock(survey, result.deformTransforms, exempt);
194
+ }
195
+
196
+ /**
197
+ * The header the `scale` rows carry, because the figure beside them lies without
198
+ * it.
199
+ *
200
+ * ⛔ Three things it has to say, and each one is a way the number is wrong if
201
+ * taken at face value:
202
+ * - it is the key's OWN factor. A nonuniform parent shears its children, so
203
+ * the drawn area is not this product;
204
+ * - a key that moved only one axis has no product to state, and gets none
205
+ * rather than an invented 1 on the other;
206
+ * - a uniform scale has a product too, and it is a zoom rather than a squash.
207
+ */
208
+ const SCALE_PRODUCT_NOTE =
209
+ '.. x·y is the key\'s own local area factor: ~1.00 is the volume kept, and it is a READING, never a rule — ' +
210
+ 'a nonuniform parent shears this, and a uniform scale has a product without being a squash';
211
+
212
+ /**
213
+ * `x·y` for a `scale` key that states both, and nothing otherwise.
214
+ *
215
+ * ⭐ Why it is here at all: `explain` ALREADY prints this reading for the other
216
+ * spelling of squash and stretch. A `transform: affine` deform key reports
217
+ * `area x1.020800`, which is exactly its own `0.88 × 1.16` — so the author who
218
+ * reaches for the advanced spelling is told whether the volume held and the
219
+ * author who reaches for the cheap one is not, while `docs/MOTION.md` §7 points
220
+ * a first candidate at the cheap one on purpose. That asymmetry is the defect;
221
+ * this is not a new kind of number (issue #377).
222
+ *
223
+ * 🔒 A reading and never an assertion. `deformReportLines` states the test a
224
+ * geometric figure has to pass to become a gate — no legitimate counter-example
225
+ * — and this fails it in quantity: a shadow, a zoom, a cartoon squash that
226
+ * gains mass on purpose. Volume preservation is a style commitment no spec can
227
+ * declare, so a bar here would be one consumer's house style failing correct
228
+ * foreign data. There is no honest SKIP either: an absent declaration is not
229
+ * "nothing to measure", it is "no way to know what was meant".
230
+ */
231
+ /**
232
+ * An emitted key as the 4.3 parser reads it: every field its kind's
233
+ * `PARSER_DEFAULTS` row lists and the key leaves out, at the row's value, then
234
+ * the key's own fields. The emitter leaves a field at its parser default out
235
+ * (issue #716), so a report that reads the file needs the row to say what the
236
+ * runtime reads there — and reads it from the one table the emitter used.
237
+ */
238
+ function asParsed(kind: string, key: Record<string, unknown>): Record<string, unknown> {
239
+ const row = PARSER_DEFAULTS[kind];
240
+ if (row === undefined) return key;
241
+ const site = { object: key, previous: () => null };
242
+ const filled: Record<string, unknown> = {};
243
+ for (const field of Object.keys(row)) {
244
+ const value = parserReading(row, site, field);
245
+ if (value !== undefined) filled[field] = value;
246
+ }
247
+ return { ...filled, ...key };
248
+ }
249
+
250
+ /** An emitted key's time as the parser reads it — `0` where the key leaves it out. */
251
+ function keyTimeOf(kind: string, key: Record<string, unknown>): unknown {
252
+ return asParsed(kind, key).time;
253
+ }
254
+
255
+ function scaleProduct(timelineName: string, key: Record<string, unknown>): string {
256
+ if (timelineName !== 'scale') return '';
257
+ const x = key.x;
258
+ const y = key.y;
259
+ // Both axes, or nothing: a key that moved one axis has no area factor, and
260
+ // defaulting the other to 1 would invent the very number being reported.
261
+ if (typeof x !== 'number' || typeof y !== 'number') return '';
262
+ return ` x·y=${(x * y).toFixed(4)}`;
263
+ }
264
+
265
+ /**
266
+ * `--as <candidate>=<reference>`, one pair per occurrence.
267
+ *
268
+ * ⚠️ Spelled with a pair where `check --as <name>` takes one name, and the
269
+ * difference is in what the two commands have on the other side. `check`
270
+ * measures against a rendered frame SET, which already carries the reference
271
+ * animation's name in its own directory, so one name closes the gap. `diff` has
272
+ * two skeletons and either may have its own vocabulary, so one name says which
273
+ * shot on which side and leaves the other unanswered. The direction — candidate
274
+ * first — is the one `bonedist`'s correspondence file already writes its
275
+ * `animations` map in.
276
+ *
277
+ * Every refusal here is a UsageError because every one of them is about the
278
+ * flag's own value, and each names what it read: a value with no `=`, an empty
279
+ * side, a name repeated on either side, and a name no animation on that side
280
+ * answers to. ⛔ The last of those is a refusal rather than a dropped pair for
281
+ * the reason a miss is refused by name everywhere else in this tool — a typo
282
+ * that quietly measured less would be a report about a pairing the caller did
283
+ * not ask for.
284
+ */
285
+ function readAnimationPairs(values: string[], candidate: unknown, reference: unknown): DiffAnimationPair[] {
286
+ const animationsOf = (root: unknown): string[] => {
287
+ const anims = (root as { animations?: unknown } | null)?.animations;
288
+ return typeof anims === 'object' && anims !== null && !Array.isArray(anims) ? Object.keys(anims) : [];
289
+ };
290
+ const have = { candidate: animationsOf(candidate), reference: animationsOf(reference) };
291
+ const pairs: DiffAnimationPair[] = [];
292
+ for (const value of values) {
293
+ const at = value.indexOf('=');
294
+ if (at < 0) {
295
+ throw new UsageError(
296
+ `--as ${JSON.stringify(value)} is not a pair. It takes <candidate>=<reference> — two animation names joined ` +
297
+ 'by `=`, because diff compares two skeletons and either may have its own name for the shot. The candidate ' +
298
+ `has [${have.candidate.join(', ') || 'none'}] and the reference has [${have.reference.join(', ') || 'none'}].`,
299
+ );
300
+ }
301
+ const pair = { candidate: value.slice(0, at), reference: value.slice(at + 1) };
302
+ if (pair.candidate === '' || pair.reference === '') {
303
+ throw new UsageError(
304
+ `--as ${JSON.stringify(value)} leaves the ${pair.candidate === '' ? 'candidate' : 'reference'} side empty; ` +
305
+ 'it takes <candidate>=<reference>, a name on each side',
306
+ );
307
+ }
308
+ for (const side of ['candidate', 'reference'] as const) {
309
+ if (!have[side].includes(pair[side])) {
310
+ throw new UsageError(
311
+ `--as ${JSON.stringify(value)} names no ${side} animation: the ${side} has ` +
312
+ `[${have[side].join(', ') || 'none'}] and not ${JSON.stringify(pair[side])}`,
313
+ );
314
+ }
315
+ if (pairs.some((p) => p[side] === pair[side])) {
316
+ throw new UsageError(
317
+ `--as pairs the ${side} animation ${JSON.stringify(pair[side])} twice; each animation may be in one pair, ` +
318
+ 'or the block would compare one shot against two',
319
+ );
320
+ }
321
+ }
322
+ pairs.push(pair);
323
+ }
324
+ return pairs;
325
+ }
326
+
327
+ export function cmdDiff(flags: Record<string, string>, lists: Record<string, string[]>, positional: string[]): void {
328
+ const [candidate, reference] = positional;
329
+ if (!candidate || !reference) throw new UsageError('diff takes two paths: <candidate.json> <reference.json>');
330
+ // A file is read as the JSON it is; a directory is a build, and its skeleton is what is compared — through the one
331
+ // statement of a build (issue #1046), which refuses a directory holding none. A directory used to reach the JSON
332
+ // reader and die on an EISDIR and a stack.
333
+ const skeletonAt = (path: string): string => {
334
+ const at = resolve(path);
335
+ if (existsSync(at) && statSync(at).isDirectory()) return resolveBuild(at, undefined).skeletonPath;
336
+ if (!existsSync(at)) throw new UsageError(`nothing at ${at}`);
337
+ return at;
338
+ };
339
+ const candidatePath = skeletonAt(candidate);
340
+ const referencePath = skeletonAt(reference);
341
+ const candidateJson = readJsonFile(candidatePath);
342
+ const referenceJson = readJsonFile(referencePath);
343
+ const animationPairs = readAnimationPairs(lists.as ?? [], candidateJson, referenceJson);
344
+ const report = diffSkeletons(candidateJson, referenceJson, { animationPairs });
345
+ console.log('rigc diff');
346
+ for (const line of diffLines(report, { candidate: candidatePath, reference: referencePath })) console.log(line);
347
+ if (flags.json !== undefined) {
348
+ // ⚠️ `...report` carries its OWN `candidate` and `reference` — the raw
349
+ // per-side counts. Spelling the paths under those two names put them on
350
+ // the losing side of the spread, so the written report named neither file
351
+ // it compared. The paths get their own keys.
352
+ writeJson(flags.json, { candidatePath, referencePath, ...report });
353
+ }
354
+ }
355
+
356
+ /**
357
+ * `check --out <dir>`, refused before anything is compared when it cannot be
358
+ * written — see `src/checkpics.ts` for what goes there.
359
+ *
360
+ * Two refusals and no others. A **file** at the path is not a directory of
361
+ * pictures, and saying so beats the `ENOTDIR` a write would throw after the
362
+ * whole comparison had run. And a directory that **is `--frames` or holds it**
363
+ * is refused because `<out>/<set>/` is cleared and `<out>/frames.json` written,
364
+ * as `render` does to its own: there, that is the reference set being deleted by
365
+ * the command that reads it — `check --frames render --out render` would clear
366
+ * `render/heavy` and put a picture sidecar where the frames' own was. A fresh
367
+ * path, and an existing directory anywhere else, are written to.
368
+ */
369
+ function readCheckOut(outFlag: string, framesFlag: string): string {
370
+ const out = resolve(outFlag);
371
+ if (existsSync(out) && !statSync(out).isDirectory()) {
372
+ throw new UsageError(`check: --out ${out} is a file; it names a directory`);
373
+ }
374
+ const frames = resolve(framesFlag);
375
+ const within = relative(out, frames);
376
+ if (within === '' || (!within.startsWith('..') && !isAbsolute(within))) {
377
+ throw new UsageError(
378
+ `check: --out ${out} ${within === '' ? 'is' : 'holds'} --frames ${frames}; the pictures would be written ` +
379
+ 'over the frames they are pictures of (each set directory under --out is cleared first) — name a directory ' +
380
+ 'outside it',
381
+ );
382
+ }
383
+ return out;
384
+ }
385
+
386
+ export function cmdCheck(flags: Record<string, string>): void {
387
+ if (flags.candidate === undefined) throw new UsageError('check needs --candidate <dir | skeleton.json>');
388
+ if (flags.frames === undefined) throw new UsageError('check needs --frames <dir> — a rendered reference frame set');
389
+ const out = flags.out === undefined ? null : readCheckOut(flags.out, flags.frames);
390
+ const allFrames = flags['all-frames'] !== undefined;
391
+ // Only asked for when there is somewhere to put the pictures: without --out
392
+ // nothing is kept, and the run is the run it was before --out existed.
393
+ const plates = out === null ? undefined : new CheckPlates({ allFrames });
394
+ const report = runCheck(flags.candidate, flags.atlas, flags.frames, flags, plates);
395
+ console.log('rigc check');
396
+ for (const line of checkLines(report, { allFrames })) console.log(line);
397
+ if (flags.json !== undefined) writeJson(flags.json, report);
398
+ if (out !== null && plates !== undefined) {
399
+ for (const set of writeCheckPictures(out, report, plates, { allFrames })) {
400
+ const which = set.frames.length === 0 ? 'nothing compared' : set.every ? 'every compared frame' : 'the frames worth reading';
401
+ console.log(` .. ${set.dir.padEnd(16)} ${set.frames.length} picture(s), ${which} -> ${set.path}`);
402
+ }
403
+ console.log(`rigc: wrote ${join(out, FRAMES_SIDECAR)}`);
404
+ }
405
+ }
406
+
407
+ /**
408
+ * `--skin`, checked against what the skeleton actually declares.
409
+ *
410
+ * ⭐ Absent is not `default`: it is "set no skin at all", which is what every
411
+ * render did before issue #571 and what `spine-core` starts a skeleton in. The
412
+ * distinction is the whole of the default path's byte-identity — see
413
+ * `FramesSidecar.skin`.
414
+ *
415
+ * The miss is refused by name with the declared names beside it, the way
416
+ * `--animation` is: a skin name is the one place a typo draws a whole rig's
417
+ * worth of the wrong art and reports a number about it.
418
+ */
419
+ function readSkinFlag(flags: Record<string, string>, declared: string[]): string | undefined {
420
+ const name = flags.skin;
421
+ if (name === undefined) return undefined;
422
+ if (!declared.includes(name)) {
423
+ throw new UsageError(
424
+ `no skin ${JSON.stringify(name)} in this skeleton; it declares [${declared.join(', ') || 'none'}]`,
425
+ );
426
+ }
427
+ return name;
428
+ }
429
+
430
+ /**
431
+ * `--slot` / `--hide`, resolved against the skeleton under the skin this run
432
+ * poses it in — or refused as a usage error, nothing written (issue #835).
433
+ *
434
+ * The rule itself is `slotSubsetOf`'s in `src/render.ts`, which `piecesOf`
435
+ * applies too: it is read here only so a miss exits 2 with the usage beside
436
+ * it before a directory is created, rather than surfacing from the sampler.
437
+ */
438
+ function readSlotSubsetFlags(
439
+ flags: Record<string, string>,
440
+ facts: SkeletonFacts,
441
+ skin: string | undefined,
442
+ ): SlotSubset | undefined {
443
+ const list = (raw: string | undefined): string[] | undefined =>
444
+ raw === undefined ? undefined : raw.split(',').map((name) => name.trim()).filter((name) => name !== '');
445
+ try {
446
+ return facts.subset({ slots: list(flags.slot), hidden: list(flags.hide) }, skin);
447
+ } catch (err) {
448
+ if (err instanceof SlotSubsetError) throw new UsageError(err.message);
449
+ throw err;
450
+ }
451
+ }
452
+
453
+ /** `--poser` as the candidate is loaded with: a known spelling, or the input's own choice — the spelling is refused later, by `readPoserFlag`. */
454
+ function posersAsked(flags: Record<string, string>): PoserName | undefined {
455
+ return POSER_NAMES.find((name) => name === flags.poser);
456
+ }
457
+
458
+ /** A resolved subset as the one field it is spelled as, in `PoseOptions` and in `frames.json` alike. */
459
+ function subsetFields(subset: SlotSubset | undefined): { slots?: string[]; hidden?: string[] } {
460
+ if (subset === undefined) return {};
461
+ return subset.mode === 'slots' ? { slots: subset.names } : { hidden: subset.names };
462
+ }
463
+
464
+ function readPositiveNumber(flags: Record<string, string>, key: string, fallback: number, least: number): number {
465
+ const raw = flags[key];
466
+ if (raw === undefined) return fallback;
467
+ const value = Number(raw);
468
+ if (!Number.isFinite(value) || value < least) throw new UsageError(`--${key} must be a number of at least ${least}`);
469
+ return value;
470
+ }
471
+
472
+ /**
473
+ * render — the frame series, drawn by the same rasteriser `check` measures with.
474
+ *
475
+ * The framing is measured across EVERY animation at `FRAMING_FPS` and not across
476
+ * the one being written, which is `src/render.ts`'s own invariant: the viewport is
477
+ * a property of the shot, so two animations of one rig — and the same animation at
478
+ * two rates — land on one pixel grid and stay comparable.
479
+ */
480
+ export function cmdRender(flags: Record<string, string>): void {
481
+ if (flags.candidate === undefined) {
482
+ throw new UsageError('needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
483
+ }
484
+ const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
485
+ const atlasDir = dirname(atlasPath);
486
+ const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
487
+ const maxSide = readPositiveNumber(flags, 'max', 256, 16);
488
+ const outRoot = resolve(flags.out ?? 'render');
489
+ const geometry = flags.geometry !== undefined;
490
+ // Refused together rather than one of them ignored (issue #864). The export
491
+ // records every slot's whole geometry, because a subset is a statement about
492
+ // which pixels are DRAWN and the pose is the same whatever a picture leaves
493
+ // out — so a geometry.json beside a subset's frames would describe slots
494
+ // those frames do not draw, and one that dropped them would stop being the
495
+ // pose. The whole-rig run writes the same file either way.
496
+ if (geometry) {
497
+ for (const flag of ['slot', 'hide'] as const) {
498
+ if (flags[flag] !== undefined) {
499
+ throw new UsageError(
500
+ `--geometry takes no --${flag}: it records every slot's whole geometry, which a subset of the drawn slots ` +
501
+ `does not change. Run \`rigc render --geometry\` on the whole rig for the file, and --${flag} ` +
502
+ `${flags[flag]} without --geometry for the pictures — both land on the same grid`,
503
+ );
504
+ }
505
+ }
506
+ }
507
+
508
+ console.log('rigc render');
509
+ console.log(` .. skeleton ${skeletonPath}`);
510
+ console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
511
+ // Which implementation of the posing seam draws this (issue #968), chosen
512
+ // before anything is read off the candidate (issue #1014): a rigc build the
513
+ // core poses — `skeleton.model.json` beside the pair — reads its names, its
514
+ // subset roster and its pages without loading spine-core at all, and with a
515
+ // rigc-compiled/2 document without its atlas either (issue #1020).
516
+ const { choice, facts, pages } = loadCandidate(
517
+ { skeletonText: readSkeletonText(skeletonPath), atlasText, atlasDir, label: skeletonPath },
518
+ { skeleton: skeletonPath, atlas: atlasPath },
519
+ posersAsked(flags),
520
+ );
521
+ const only = readAnimationFlag(flags, [...facts.animations]);
522
+ const skin = readSkinFlag(flags, [...facts.skins]);
523
+ const subset = readSlotSubsetFlags(flags, facts, skin);
524
+ // One object, so the framing and the frames cannot be posed under two
525
+ // different skins — which would frame one shot with another shot's box.
526
+ // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
527
+ // `src/render.ts` has one of its own. The inferred shape is the render one.
528
+ // The subset rides on the same object and `framingViewport` takes it off, so
529
+ // the frames draw the subset and the box is still the whole rig's.
530
+ const pose =
531
+ skin === undefined && subset === undefined
532
+ ? undefined
533
+ : { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset) };
534
+ if (skin !== undefined) console.log(` .. skin ${skin}`);
535
+ if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
536
+
537
+ // Rigc's own core when the candidate is a rigc build, and spine-core
538
+ // otherwise, or where the core refuses the input by name. Never silently:
539
+ // the `poser` line below says which, and why. `--poser` is refused here, where
540
+ // it always was: a spelling there is no poser for, then `core` on an input
541
+ // that cannot carry it.
542
+ readPoserFlag(flags);
543
+ refuseUnchosen(choice);
544
+
545
+ // Everything is posed before anything is written, so a core refusal partway
546
+ // re-poses the whole input on spine-core rather than leaving half of a frame
547
+ // set drawn by each.
548
+ const posed = throughPoser(choice, (poser, roster) => {
549
+ // `null` is a skeleton that posed no vertex at all. One that posed a vertex
550
+ // it cannot frame — Infinity or NaN — is thrown from the framing as a
551
+ // `GeometryError` naming the number (issue #873), and one whose every vertex
552
+ // sits at one point — every drawn bone unposed by the skin, or collapsed — as
553
+ // an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
554
+ // lets the second name the skins that pose a bone, whichever poser draws.
555
+ const viewport = framingViewport(poser, maxSide, pose, roster);
556
+ if (!viewport) {
557
+ throw new UsageError(
558
+ `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
559
+ skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
560
+ } — there is nothing to draw`,
561
+ );
562
+ }
563
+
564
+ // `sampleAll` covers the skeleton with no animation at all, which files its one
565
+ // setup-pose frame under the reserved name. Narrowing to one animation reuses
566
+ // the same sampler rather than a second path through it.
567
+ //
568
+ // `--geometry` rides on the SAME call (issue #864): the bones and the whole
569
+ // attachments are read off the skeleton at the step that drew each frame, so
570
+ // the export's grid is this frame set's by construction.
571
+ const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
572
+ const sampled: Map<string, Frame[]> =
573
+ only === undefined
574
+ ? sampleAll(poser, fps, sampling)
575
+ : new Map([[only, sampleAnimation(poser, only, fps, sampling)]]);
576
+ // Every file's text before the first write, so a refused number leaves the
577
+ // output directory as it was rather than half of a frame set behind it.
578
+ const geometryTexts = new Map<string, string>();
579
+ if (geometry) {
580
+ for (const [name, frames] of sampled) {
581
+ const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
582
+ geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
583
+ }
584
+ }
585
+ return { viewport, sampled, geometryTexts };
586
+ });
587
+ const { viewport, sampled, geometryTexts } = posed.value;
588
+ console.log(` .. poser ${posed.note}`);
589
+ console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
590
+ if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
591
+
592
+ mkdirSync(outRoot, { recursive: true });
593
+ const sets: FrameSet[] = [];
594
+ for (const [name, frames] of sampled) {
595
+ // Same naming as a reference render: the protocol rate says nothing, any
596
+ // other rate says itself, so two rates of one animation sit side by side.
597
+ const dirName = fps === PROTOCOL_FPS ? name : `${name}@${fps}fps`;
598
+ const dir = join(outRoot, dirName);
599
+ // Cleared rather than written over: a shorter animation would otherwise leave
600
+ // the tail of a longer previous run on disk, and stale frames in a frame set
601
+ // are indistinguishable from real ones.
602
+ if (existsSync(dir)) rmSync(dir, { recursive: true });
603
+ mkdirSync(dir, { recursive: true });
604
+ for (let i = 0; i < frames.length; i++) {
605
+ renderFrame(frames[i], pages, viewport, BACKGROUND).writePng(join(dir, `f${String(i).padStart(4, '0')}.png`));
606
+ }
607
+ // One frame has nothing to compare itself against, so it gets no sheet — it
608
+ // would be the same picture with a border and a "0" on it.
609
+ const sheet = frames.length > 1;
610
+ if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
611
+ const geometryOut = geometryTexts.get(name);
612
+ if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
613
+ const duration = frames[frames.length - 1].time;
614
+ sets.push({
615
+ dir: dirName,
616
+ animation: name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name,
617
+ fps,
618
+ sampled: frames.length,
619
+ written: frames.length,
620
+ stride: 1,
621
+ duration,
622
+ });
623
+ const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
624
+ const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}`;
625
+ console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
626
+ }
627
+
628
+ // The sidecar is what makes this a frame SET rather than a pile of pictures:
629
+ // the world box every frame is a picture of, so a distance measured in pixels
630
+ // converts back to the units the rig is authored in — and so `rigc check` can
631
+ // render something else into the same grid later.
632
+ const sidecar: FramesSidecar = {
633
+ spec: FRAMES_SPEC,
634
+ // Written only when a skin was asked for: absent says "no skin was set",
635
+ // which is both what this run did and what every frame set written before
636
+ // #571 did. See `FramesSidecar.skin`.
637
+ ...(skin === undefined ? {} : { skin }),
638
+ // Written only when a subset was asked for, for the same reason: a render of
639
+ // every slot says nothing and stays the bytes it always was (issue #835).
640
+ ...subsetFields(subset),
641
+ background: BACKGROUND,
642
+ // The one spelling `geometry.json` repeats, so the two files cannot state
643
+ // two boxes for one grid.
644
+ viewport: sidecarViewport(viewport),
645
+ sets: [...sets].sort((a, b) => a.dir.localeCompare(b.dir)),
646
+ };
647
+ writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
648
+ console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
649
+ }
650
+
651
+ /** `--scale 0.5,2` / `--rotation -30,30` — a pair of numbers, low first. */
652
+ function readRange(flags: Record<string, string>, key: string): { low: number; high: number } | undefined {
653
+ const raw = flags[key];
654
+ if (raw === undefined) return undefined;
655
+ const parts = raw.split(',').map((s) => Number(s.trim()));
656
+ if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n))) {
657
+ throw new UsageError(`--${key} takes two numbers: <min>,<max>`);
658
+ }
659
+ if (parts[1] < parts[0]) throw new UsageError(`--${key} ${JSON.stringify(raw)}: the minimum must not exceed the maximum`);
660
+ return { low: parts[0], high: parts[1] };
661
+ }
662
+
663
+ export function cmdPose(flags: Record<string, string>): void {
664
+ if (flags.images === undefined) throw new UsageError('pose needs --images <dir> — the directory the loose part PNGs are in');
665
+ if (flags.frame === undefined) throw new UsageError('pose needs --frame <path> — one pose frame to read the placements out of');
666
+ const options: PoseOptions = { imagesDir: flags.images, framePath: flags.frame };
667
+ const scale = readRange(flags, 'scale');
668
+ if (scale) {
669
+ if (scale.low <= 0) throw new UsageError('--scale minimum must be greater than zero');
670
+ options.scale = { min: scale.low, max: scale.high };
671
+ }
672
+ const rotation = readRange(flags, 'rotation');
673
+ if (rotation) {
674
+ if (rotation.high - rotation.low > 360) throw new UsageError('--rotation cannot span more than a full turn');
675
+ options.rotation = { minDeg: rotation.low, maxDeg: rotation.high };
676
+ }
677
+ if (flags['max-residual'] !== undefined) {
678
+ const value = Number(flags['max-residual']);
679
+ if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--max-residual must be a number in (0, 1]');
680
+ options.maxResidual = value;
681
+ }
682
+
683
+ console.log('rigc pose');
684
+ const report = estimatePose(options);
685
+ for (const line of poseLines(report)) console.log(line);
686
+
687
+ // Same `--out` shape as `preview` and `vote`: one file, and a directory means
688
+ // "the default name in here" rather than a report written over a directory.
689
+ const target = resolve(flags.out ?? DEFAULT_POSE_OUT);
690
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_POSE_OUT) : target;
691
+ writeJson(out, report);
692
+ }
693
+
694
+ export function cmdChainFit(flags: Record<string, string>): void {
695
+ if (flags.candidate === undefined) {
696
+ throw new UsageError('chainfit needs --candidate <dir | skeleton.json> — the compiled rig to read the frame through');
697
+ }
698
+ if (flags.images === undefined) {
699
+ throw new UsageError("chainfit needs --images <dir> — where the candidate's attachment image names resolve to PNGs");
700
+ }
701
+ if (flags.frame === undefined) throw new UsageError('chainfit needs --frame <path> — one pose frame to read the placements out of');
702
+ // Refused rather than ignored. Every other --candidate command takes --atlas, so
703
+ // passing it here is a reasonable thing to try — and a flag that silently does
704
+ // nothing is worse than one that says why it cannot.
705
+ if (flags.atlas !== undefined) {
706
+ throw new UsageError(
707
+ 'chainfit reads no atlas: the part art comes from --images, one PNG per attachment image name, and the ' +
708
+ 'skeleton is all it needs of the candidate. Drop --atlas',
709
+ );
710
+ }
711
+ // The candidate through the one statement of a build (issue #1046): a path, or a directory holding no skeleton.json,
712
+ // is refused here as by every command that takes a build, rather than by `src/chainfit.ts`'s own reader in its own words.
713
+ resolveBuild(flags.candidate, undefined);
714
+ const options: ChainFitOptions = {
715
+ candidatePath: flags.candidate,
716
+ imagesDir: flags.images,
717
+ framePath: flags.frame,
718
+ };
719
+ if (flags.anchor !== undefined) options.anchorPath = flags.anchor;
720
+ const hinge = readRange(flags, 'hinge');
721
+ if (hinge) {
722
+ if (hinge.high - hinge.low > 360) throw new UsageError('--hinge cannot span more than a full turn');
723
+ options.hinge = { minDeg: hinge.low, maxDeg: hinge.high };
724
+ }
725
+ if (flags.stretch !== undefined) {
726
+ const value = Number(flags.stretch);
727
+ if (!Number.isFinite(value) || value < 1) throw new UsageError('--stretch must be a ratio of 1 or more, e.g. 1.25');
728
+ options.stretch = value;
729
+ }
730
+ if (flags['min-visible'] !== undefined) {
731
+ const value = Number(flags['min-visible']);
732
+ if (!Number.isFinite(value) || value < 0 || value > 1) throw new UsageError('--min-visible must be a number in [0, 1]');
733
+ options.minVisible = value;
734
+ }
735
+ if (flags['max-residual'] !== undefined) {
736
+ const value = Number(flags['max-residual']);
737
+ if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--max-residual must be a number in (0, 1]');
738
+ options.maxResidual = value;
739
+ }
740
+ if (flags.passes !== undefined) {
741
+ const value = Number(flags.passes);
742
+ if (!Number.isInteger(value) || value < 1 || value > 8) throw new UsageError('--passes must be a whole number in 1..8');
743
+ options.passes = value;
744
+ }
745
+ if (flags['inward-lever'] !== undefined) {
746
+ const value = Number(flags['inward-lever']);
747
+ if (!Number.isFinite(value) || value < 0) throw new UsageError('--inward-lever must be a number of frame pixels, 0 or more');
748
+ options.minLeverPx = value;
749
+ }
750
+ if (flags['anchor-residual'] !== undefined) {
751
+ const value = Number(flags['anchor-residual']);
752
+ if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--anchor-residual must be a number in (0, 1]');
753
+ options.anchorMaxResidual = value;
754
+ }
755
+ const scale = readRange(flags, 'scale');
756
+ if (scale) {
757
+ if (scale.low <= 0) throw new UsageError('--scale minimum must be greater than zero');
758
+ options.scale = { min: scale.low, max: scale.high };
759
+ }
760
+ const rotation = readRange(flags, 'rotation');
761
+ if (rotation) {
762
+ if (rotation.high - rotation.low > 360) throw new UsageError('--rotation cannot span more than a full turn');
763
+ options.rotation = { minDeg: rotation.low, maxDeg: rotation.high };
764
+ }
765
+
766
+ console.log('rigc chainfit');
767
+ const report = estimateChainFit(options);
768
+ for (const line of chainFitLines(report)) console.log(line);
769
+
770
+ const target = resolve(flags.out ?? DEFAULT_CHAINFIT_OUT);
771
+ const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_CHAINFIT_OUT) : target;
772
+ writeJson(out, report);
773
+ }
774
+
775
+ /**
776
+ * Refuse a compiled pair whose art `explain` cannot pose through, by name.
777
+ *
778
+ * 🚨 `explain` poses the rig — `deformReportLines` measures what each deform key
779
+ * did — and spine-core's reading of the pair resolves EVERY attachment against
780
+ * the atlas, whether or not anything deforms it. (Since issue #1019 a build's
781
+ * survey is read off its model document and posed by the core, which resolves
782
+ * nothing against the atlas; spine-core reads it under `--poser spine` and on a
783
+ * fallback, and this refusal is unchanged and comes before either.) On the specs
784
+ * `ingest --art none` writes there is nothing to resolve against: the entries
785
+ * state a size and name no `image`, so the compile atlases nothing, and the load
786
+ * threw the runtime's own `Region not found in atlas: rear-upper-arm (attachment:
787
+ * rear-upper-arm)` with a spine-core stack trace under it and exit 1 (measured on
788
+ * `examples/spineboy/export/spineboy-ess.json`, issue #697). That is the tool
789
+ * telling an agent about its own internals instead of about the rig, on the one
790
+ * input `docs/INGEST.md` §2.0 documents as the route through a foreign skeleton.
791
+ *
792
+ * ⭐ Both halves of the join are rigc's own readers rather than a second opinion
793
+ * on somebody else's format: the walk is `attachmentRegionJoins`, which `PS127`
794
+ * measures against the loader's own `findRegion` calls, and the region names come
795
+ * from `parseAtlasText`, which is what `--atlas-in` already resolves against.
796
+ * Names are compared EXACTLY — as `A08` compares them and as `findRegion`
797
+ * matches them — so a padded region name is a miss on both sides.
798
+ *
799
+ * ⚠️ What it deliberately does not do is catch the pose. A blanket `try` around
800
+ * `skeletonDataFromText` would convert any exception the runtime raises into a
801
+ * sentence claiming the cause is missing art, and a rigc defect reported under
802
+ * somebody else's name is the doctrine's second bullet inverted. This refuses
803
+ * the case it can NAME, before a line of the report is printed, and leaves
804
+ * anything else to arrive as itself.
805
+ */
806
+ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
807
+ const regionNames = parseAtlasText(result.atlasText).pages.flatMap((page) => page.regions.map((region) => region.name));
808
+ const have = new Set(regionNames);
809
+ const misses: Array<{ at: string; attachment: string; lookup: string }> = [];
810
+ let lookups = 0;
811
+ for (const join of attachmentRegionJoins(JSON.parse(result.skeletonText))) {
812
+ // A `sequence` this walk will not guess at names no region it can check, and
813
+ // `A08` passes over it for the same reason.
814
+ if (join.lookups === null) continue;
815
+ for (const lookup of join.lookups) {
816
+ lookups++;
817
+ if (!have.has(lookup)) {
818
+ misses.push({
819
+ at: `skin "${join.skin}" slot "${join.slot}" placeholder "${join.placeholder}"`,
820
+ attachment: join.name,
821
+ lookup,
822
+ });
823
+ }
824
+ }
825
+ }
826
+ if (misses.length === 0) return;
827
+ const first = misses[0];
828
+ // Reported because it was measured, and absent where there is none — `A08`'s
829
+ // own near-miss clause, in `A08`'s own words.
830
+ const near = regionNames.find((region) => region.trim().toLowerCase() === first.lookup.toLowerCase());
831
+ const has =
832
+ regionNames.length === 0
833
+ ? 'it declares no region at all'
834
+ : `it declares ${regionNames.length} region(s) and none of them is that${
835
+ near === undefined ? '' : `, though it does have ${JSON.stringify(near)}`
836
+ }`;
837
+ const remedy =
838
+ opts.atlasInPath === undefined
839
+ ? 'Art reaches a compile two ways and this run took neither: `--atlas-in <pack.atlas>` resolves the parts ' +
840
+ 'against a pack somebody already made, and an "image" per attachment resolves them as loose PNGs under ' +
841
+ '`--images <dir>` — a spec that states a size and names no image is what `ingest --art none` writes, and ' +
842
+ '`--atlas-in` is what reads it'
843
+ : `Either the spec's region name or ${opts.atlasInPath} is the one that moved: fix the name, or point ` +
844
+ '`--atlas-in` at the pack that has it';
845
+ throw new ExplainError(
846
+ `${first.at}: attachment ${JSON.stringify(first.attachment)} wants region ${JSON.stringify(first.lookup)}, ` +
847
+ `which this build's atlas does not have (${has}). \`explain\` poses the rig to measure its deform keys and a ` +
848
+ `pose resolves every attachment against the atlas, so there is nothing to pose it against. ${remedy}. ` +
849
+ `${misses.length} of ${lookups} attachment lookup(s) here resolve to no region.`,
850
+ );
851
+ }
852
+
853
+ export function cmdExplain(flags: Record<string, string>): void {
854
+ // `--poser` is refused by its spelling before anything compiles (issue #1019): it chooses the deform survey's reader and poser.
855
+ const poser = readPoserFlag(flags);
856
+ const { label, opts } = resolveCut(flags);
857
+ console.log(`rigc explain ${label}`);
858
+ // See the identical pair of lines in `cmdBuild` for why both paths are named
859
+ // here rather than only the one the header's `label` happens to carry.
860
+ console.log(` .. rig ${opts.rigPath}`);
861
+ console.log(` .. motion ${opts.motionPath}`);
862
+ const result = compile(opts);
863
+ // Before a line of the report, rather than at the pose two hundred lines in:
864
+ // the blocks that need the art — DEFORM, meshes, dropped states — all sit
865
+ // BELOW the pose, so a run that printed the bone and timeline dump and then
866
+ // refused would be a report missing everything the missing art decides, with
867
+ // the sentence saying so scrolled off the top. It is the invocation that has
868
+ // to change, so it is refused before the report it cannot finish (issue #697).
869
+ refuseUnposableArt(result, opts);
870
+ // 📐 **A page whose file is not its declared size is said where the report
871
+ // starts, and nothing is refused for it** (issue #750). `explain` never
872
+ // gates, so on such a pack nothing stood between the region lift and the
873
+ // figures it fed: the mesh block printed a fit taken off another part of the
874
+ // page. The pack still compiles — a runtime draws it, and most of this report
875
+ // (bones, slots, timelines) reads no texel at all — so the page is named
876
+ // here, once, with the ratio `A06` refuses it by, and every figure that WOULD
877
+ // have been taken off its texels is withheld on its own line and says so.
878
+ // `build` prints no such line: `A06` is its statement of the same fact.
879
+ for (const grid of result.pageGrids) {
880
+ console.log(
881
+ ` .. ${grid.said}: every figure below taken off this page's texels is withheld, and says so where it ` +
882
+ `would have stood. ${PAGE_GRID_UNLOCATED}`,
883
+ );
884
+ }
885
+ // `compile` has already parsed this file, so the read below cannot fail — but
886
+ // it goes through the same parser rather than a cast, because the cast was the
887
+ // last one in the repository and issue #307 was about exactly that.
888
+ const motion = parseMotionSpec(readJsonFile(opts.motionPath), opts.motionPath);
889
+
890
+ // A rig may state that it has no stage at all (issue #578), and the two must
891
+ // not print alike: `undefined x undefined` is what a template does with an
892
+ // absence, and it reads like a defect in the tool rather than a claim in the
893
+ // spec. The stage is the model's (issue #907): the header's box is the
894
+ // setup-pose bounding box, which is not the stage and is not printed as it.
895
+ const stage = result.model.stage === null ? 'none declared' : `${result.model.stage.width} x ${result.model.stage.height}`;
896
+ console.log(`\nstage ${stage} (spine ${result.skeleton.skeleton.spine})`);
897
+
898
+ // The crop note describes where the numbers CAME from, and without a manifest
899
+ // they came from the rig spec's own literals — there is no crop to be relative
900
+ // to. Printing it anyway told a rung-3 author their bone positions were in a
901
+ // coordinate system that did not exist in their rig.
902
+ const frame = opts.manifestPath ? ' (crop y-down -> spine y-up, origin at the bottom-left of the crop)' : ' (spine world: y up)';
903
+ console.log(`\nbones${frame}`);
904
+ for (const b of result.skeleton.bones) {
905
+ // `rotation` is the axis keystone and the grips' radial facing, so it earns
906
+ // a column even though it is absent on most bones.
907
+ const rot = b.rotation === undefined ? '' : ` rotation=${b.rotation}`;
908
+ console.log(` ${b.name.padEnd(12)} parent=${(b.parent ?? '-').padEnd(10)} x=${b.x ?? 0} y=${b.y ?? 0}${rot}`);
909
+ }
910
+
911
+ console.log('\nslots (array order IS the draw order)');
912
+ // The DEFAULT skin's placeholders, resolved by name. It was `skins[0]` until
913
+ // issue #541, which is the same thing only because rigc pins `default` at
914
+ // index 0 — a property of the emitter that this report should not be quietly
915
+ // relying on. Reading it by name means a change to the skins ORDER cannot turn
916
+ // this line into a report about some other skin.
917
+ const defaultSkin = result.skeleton.skins.find((skin) => skin.name === 'default');
918
+ // ...and a skeleton may declare none (issue #801), in which case every
919
+ // `attachments=[]` below is true and says nothing — so the line above the
920
+ // column says where the placeholders are instead of letting it read as
921
+ // "this rig has no art".
922
+ if (defaultSkin === undefined) {
923
+ const names = result.skeleton.skins.map((skin) => JSON.stringify(skin.name));
924
+ console.log(
925
+ ` (this skeleton declares no default skin, so the attachments column lists none; its placeholders are in ` +
926
+ `${names.length === 0 ? 'no skin at all' : `the named skin(s) ${names.join(', ')}`})`,
927
+ );
928
+ }
929
+ for (const s of result.skeleton.slots) {
930
+ const atts = Object.keys(defaultSkin?.attachments[s.name] ?? {});
931
+ console.log(
932
+ ` ${s.name.padEnd(12)} bone=${s.bone.padEnd(12)} setup=${(s.attachment ?? 'null').padEnd(22)} color=${s.color ?? 'ffffffff'} attachments=[${atts.join(', ')}]`,
933
+ );
934
+ }
935
+
936
+ console.log('\nanimations');
937
+ for (const [animName, anim] of Object.entries(result.skeleton.animations)) {
938
+ const spec = motion.animations[animName];
939
+ console.log(` ${animName} declared=${spec.duration}s loop=${spec.loop}`);
940
+ for (const [boneName, timelines] of Object.entries(anim.bones ?? {})) {
941
+ // "(mesh tier)" is a claim about what the bone DRIVES, and it was printed
942
+ // on every bone track regardless — which reads, on a rig with no mesh in
943
+ // it at all, as though the track were deforming one.
944
+ const drives = result.meshBones.includes(boneName) ? ' <- drives a mesh' : '';
945
+ for (const [timelineName, keys] of Object.entries(timelines)) {
946
+ console.log(` ${boneName}.${timelineName} ${keys.length} key(s)${drives}`);
947
+ if (timelineName === 'scale') console.log(` ${SCALE_PRODUCT_NOTE}`);
948
+ for (const emitted of keys) {
949
+ // Every channel, as the parser reads it: a channel at its parser
950
+ // default is not in the file (issue #716), and this list shows the
951
+ // value a reader has to reason about rather than the bytes.
952
+ const key = asParsed(`bone ${timelineName} key`, emitted);
953
+ const fields = Object.entries(key)
954
+ .filter(([k]) => k !== 'time' && k !== 'curve')
955
+ .map(([k, v]) => `${k}=${String(v)}`)
956
+ .join(' ');
957
+ const curve = Array.isArray(key.curve)
958
+ ? `bezier[${key.curve.length}]`
959
+ : key.curve === 'stepped'
960
+ ? 'stepped'
961
+ : 'linear';
962
+ console.log(` t=${String(key.time).padEnd(7)} ${fields.padEnd(30)} ${curve}${scaleProduct(timelineName, key)}`);
963
+ }
964
+ }
965
+ }
966
+ for (const [slotName, timelines] of Object.entries(anim.slots ?? {})) {
967
+ for (const [timelineName, keys] of Object.entries(timelines)) {
968
+ console.log(` ${slotName}.${timelineName} ${keys.length} key(s)`);
969
+ for (const emitted of keys) {
970
+ const key = asParsed(`slot ${timelineName} key`, emitted);
971
+ const curve = key.curve;
972
+ const shape = Array.isArray(curve)
973
+ ? `bezier[${curve.length}] ${curve.slice(12).join(', ')} <- alpha channel, absolute (t,v)`
974
+ : curve === 'stepped'
975
+ ? 'stepped'
976
+ : timelineName === 'attachment'
977
+ ? 'stepped (attachment timelines always are)'
978
+ : 'linear';
979
+ const value = 'color' in key ? `#${String(key.color)}` : `attachment=${String(key.name)}`;
980
+ console.log(` t=${String(key.time).padEnd(7)} ${value.padEnd(30)} ${shape}`);
981
+ }
982
+ }
983
+ }
984
+ // The two constraint groups: one unnamed timeline per constraint, so the
985
+ // name printed is the constraint's and there is no timeline name to print
986
+ // beside it. Every field a key carries is shown, because each one is
987
+ // optional in the file and the ABSENT ones are what a reader has to see —
988
+ // an omitted `softness` is 0, not "unchanged".
989
+ for (const group of ['ik', 'transform'] as const) {
990
+ for (const [name, keys] of Object.entries(anim[group] ?? {})) {
991
+ console.log(` ${group}.${name} ${keys.length} key(s) <- one timeline per constraint`);
992
+ for (const key of keys) {
993
+ const fields = Object.entries(key)
994
+ .filter(([k]) => k !== 'time' && k !== 'curve')
995
+ .map(([k, v]) => `${k}=${String(v)}`)
996
+ .join(' ');
997
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
998
+ console.log(` t=${String(keyTimeOf(`${group} key`, key)).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
999
+ }
1000
+ }
1001
+ }
1002
+ // The other two constraint groups. These DO carry a timeline name under the
1003
+ // constraint (`path.<name>.position`), which is the physics shape rather
1004
+ // than the ik/transform one, so the name printed is both.
1005
+ for (const group of ['path', 'slider'] as const) {
1006
+ for (const [name, timelines] of Object.entries(anim[group] ?? {})) {
1007
+ for (const [timelineName, keys] of Object.entries(timelines)) {
1008
+ console.log(` ${group}.${name}.${timelineName} ${keys.length} key(s)`);
1009
+ for (const key of keys) {
1010
+ const fields = Object.entries(key)
1011
+ .filter(([k]) => k !== 'time' && k !== 'curve')
1012
+ .map(([k, v]) => `${k}=${String(v)}`)
1013
+ .join(' ');
1014
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1015
+ console.log(` t=${String(keyTimeOf(`${group} ${timelineName} key`, key)).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
1016
+ }
1017
+ }
1018
+ }
1019
+ }
1020
+ // Deform timelines are keyed on a skin/slot/attachment triple, and the run
1021
+ // is printed as its span rather than its numbers: `offset` plus a length is
1022
+ // what tells a reader whether the key lands where they meant, and a hundred
1023
+ // vertex offsets on one line tells them nothing.
1024
+ for (const [skinName, slotMap] of Object.entries(anim.attachments ?? {})) {
1025
+ for (const [slotName, attMap] of Object.entries(slotMap)) {
1026
+ for (const [attName, timelines] of Object.entries(attMap)) {
1027
+ for (const [timelineName, keys] of Object.entries(timelines)) {
1028
+ console.log(` ${skinName}/${slotName}/${attName}.${timelineName} ${keys.length} key(s)`);
1029
+ for (const emitted of keys) {
1030
+ const key = asParsed(`attachment ${timelineName} key`, emitted);
1031
+ const run = Array.isArray(key.vertices) ? (key.vertices as number[]) : null;
1032
+ const offset = typeof key.offset === 'number' ? key.offset : 0;
1033
+ const span = run
1034
+ ? `deform[${offset}..${offset + run.length}] ${run.length / 2} pair(s)`
1035
+ : 'back to the setup pose';
1036
+ const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
1037
+ console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
1038
+ // A generated key prints its MODEL and then every offset the model
1039
+ // produced (issue #294). Both halves are the point: the model is
1040
+ // what a reviewer checks a claim against, and the offsets are what
1041
+ // reaches the file — printing only the first would ask a reader to
1042
+ // trust an evaluation they cannot see, which is the gap FACE §9.3
1043
+ // records. The numbers are the emitted ones, not a second
1044
+ // evaluation, so this block and the artifact cannot disagree.
1045
+ const gen = result.deformTransforms.find(
1046
+ (g) => g.animation === animName && g.skin === skinName && g.slot === slotName && g.attachment === attName && g.time === key.time,
1047
+ );
1048
+ if (gen === undefined) continue;
1049
+ console.log(` transform ${gen.kind} ${gen.stated}`);
1050
+ console.log(` ${gen.formula}`);
1051
+ for (const line of gen.derived) console.log(` ${line}`);
1052
+ console.log(
1053
+ ` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
1054
+ );
1055
+ for (let v = 0; v < gen.vertexCount; v += 4) {
1056
+ const pairs: string[] = [];
1057
+ for (let k = v; k < Math.min(v + 4, gen.vertexCount); k++) {
1058
+ pairs.push(`v${String(k).padStart(3)} (${gen.offsets[2 * k]}, ${gen.offsets[2 * k + 1]})`);
1059
+ }
1060
+ console.log(` ${pairs.join(' ')}`);
1061
+ }
1062
+ // On a multi-influence attachment those pairs are the model's
1063
+ // WORLD displacements, and the file holds one `Mᵢ⁻¹·D` pair per
1064
+ // influence instead (issue #389). Printing the first without the
1065
+ // second would put numbers on the screen that are nowhere in the
1066
+ // artifact — the exact gap this block exists to close.
1067
+ if (gen.expanded !== undefined) {
1068
+ console.log(
1069
+ ` written as ${gen.expanded.length / 2} per-influence pair(s), each vertex's D through ` +
1070
+ 'its own bone inverse',
1071
+ );
1072
+ for (let i = 0; i < gen.expanded.length / 2; i += 4) {
1073
+ const pairs: string[] = [];
1074
+ for (let k = i; k < Math.min(i + 4, gen.expanded.length / 2); k++) {
1075
+ pairs.push(`i${String(k).padStart(3)} (${gen.expanded[2 * k]}, ${gen.expanded[2 * k + 1]})`);
1076
+ }
1077
+ console.log(` ${pairs.join(' ')}`);
1078
+ }
1079
+ }
1080
+ }
1081
+ }
1082
+ }
1083
+ }
1084
+ }
1085
+ // The draw-order timeline names no target, so it hangs off the animation
1086
+ // rather than off a slot — and a timeline `explain` did not print would be a
1087
+ // timeline nobody could check without reading the emitted JSON.
1088
+ if (anim.drawOrder) {
1089
+ console.log(` drawOrder ${anim.drawOrder.length} key(s) <- whole animation, offsets against the SETUP order`);
1090
+ for (const key of anim.drawOrder) {
1091
+ const offsets = Array.isArray(key.offsets)
1092
+ ? (key.offsets as Array<{ slot: string; offset: number }>)
1093
+ .map((o) => `${o.slot}${o.offset >= 0 ? '+' : ''}${o.offset}`)
1094
+ .join(' ')
1095
+ : 'back to the setup order';
1096
+ console.log(` t=${String(keyTimeOf('drawOrder key', key)).padEnd(7)} ${offsets}`);
1097
+ }
1098
+ }
1099
+ }
1100
+
1101
+ // The `MEMBER` block sits beside the `DEFORM` one and for the same reason:
1102
+ // both re-print timelines the reader has just read, in the arrangement the
1103
+ // question needs rather than the one the format has.
1104
+ for (const line of memberReportLines(result)) console.log(line);
1105
+
1106
+ // The `DEFORM` block goes after the timelines and before the constraints,
1107
+ // because it is a measurement OF the deform timelines printed above — the keys
1108
+ // it names are the keys the reader has just read, by the same index.
1109
+ for (const line of deformReportLines(result, new Set(result.rig.deformMayFold), poser, label)) console.log(line);
1110
+
1111
+ if (result.physics.length) {
1112
+ console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
1113
+ for (const ph of result.physics) {
1114
+ console.log(` ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] mix=${ph.mix} drivesMesh=${ph.drivesMesh}`);
1115
+ }
1116
+ }
1117
+
1118
+ // Path constraints, with the curve each one follows MEASURED — its length and
1119
+ // its curve count are the two numbers an author cannot get from the spec, and
1120
+ // `position` means nothing without the first of them under `positionMode:
1121
+ // "percent"`. Read off the emitted attachment rather than recomputed here.
1122
+ const constraintsOf = (type: string) => (result.skeleton.constraints ?? []).filter((c) => c.type === type);
1123
+ const pathConstraints = constraintsOf('path');
1124
+ if (pathConstraints.length) {
1125
+ console.log('\npath constraints (position is a fraction of the measured length under positionMode "percent")');
1126
+ for (const c of pathConstraints) {
1127
+ const slot = String(c.slot);
1128
+ const attachments = result.skeleton.skins.flatMap((skin) => Object.values(skin.attachments[slot] ?? {}));
1129
+ const curve = attachments.find((att) => (att as { type?: string }).type === 'path') as
1130
+ | { lengths?: number[]; closed?: boolean; constantSpeed?: boolean }
1131
+ | undefined;
1132
+ // `vertexCount / 3` entries on both shapes since issue #804, so an open
1133
+ // path's curves are one fewer than its entries and its length is the last
1134
+ // CURVE's entry — the trailing one is the wrap-around curve nothing reads.
1135
+ const lengths = (curve?.lengths ?? []).slice(0, curve?.closed ? undefined : -1);
1136
+ console.log(
1137
+ ` ${c.name.padEnd(12)} slot=${slot.padEnd(12)} bones=[${(c.bones as string[]).join(', ')}] ` +
1138
+ `position=${c.position ?? 0} ${String(c.positionMode ?? 'percent')}/${String(c.spacingMode ?? 'length')}/${String(c.rotateMode ?? 'tangent')}`,
1139
+ );
1140
+ console.log(
1141
+ ` ${''.padEnd(12)} curve: ${lengths.length} curve(s), ${lengths[lengths.length - 1] ?? 0} long, ` +
1142
+ `${curve?.closed ? 'closed' : 'open'}, constantSpeed=${curve?.constantSpeed ?? true}`,
1143
+ );
1144
+ }
1145
+ }
1146
+
1147
+ const sliders = constraintsOf('slider');
1148
+ if (sliders.length) {
1149
+ console.log('\nsliders (each applies one animation at a time it chooses)');
1150
+ for (const c of sliders) {
1151
+ const driver = c.bone === undefined ? `time=${c.time ?? 0} (keyed by slider.${c.name}.time)` : `bone=${String(c.bone)}.${String(c.property)}`;
1152
+ console.log(
1153
+ ` ${c.name.padEnd(12)} applies=${String(c.animation).padEnd(14)} ${driver} ` +
1154
+ `mix=${c.mix ?? 1} loop=${c.loop ?? false} additive=${c.additive ?? false}`,
1155
+ );
1156
+ }
1157
+ }
1158
+
1159
+ // Which bones and constraints a skin switches on. Printed because the pairing
1160
+ // with `skin: true` is invisible in the emitted file: a member list and a
1161
+ // skinRequired flag are two keys in two places, and only together do they mean
1162
+ // "this bone belongs to this skin".
1163
+ const skinMembers = result.skeleton.skins.filter((skin) => skin.bones?.length || skin.ik?.length || skin.transform?.length || skin.path?.length || skin.physics?.length || skin.slider?.length);
1164
+ if (skinMembers.length) {
1165
+ console.log('\nskin members (skinRequired bones and constraints, active only under their own skin)');
1166
+ for (const skin of skinMembers) {
1167
+ const lists = (['bones', 'ik', 'transform', 'path', 'physics', 'slider'] as const)
1168
+ .filter((key) => skin[key]?.length)
1169
+ .map((key) => `${key}=[${skin[key]!.join(', ')}]`)
1170
+ .join(' ');
1171
+ console.log(` ${skin.name.padEnd(12)} ${lists}`);
1172
+ }
1173
+ }
1174
+
1175
+ if (result.meshes.length) {
1176
+ console.log('\nmeshes');
1177
+ for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
1178
+ for (const m of result.meshes) {
1179
+ // The depth block belongs here more than it belongs in `build`: `explain`
1180
+ // is the command that says what a spec MEANS, and the turn ceiling is the
1181
+ // number an author needs before writing a key rather than after a refusal.
1182
+ // It was absent, while `docs/AUTHORING.md` said both commands printed it.
1183
+ console.log(
1184
+ ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
1185
+ `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}` +
1186
+ meshDepthNote(m) +
1187
+ meshInfluenceNote(m),
1188
+ );
1189
+ }
1190
+ }
1191
+
1192
+ if (result.droppedStates.length) {
1193
+ // The heading said "no PNG on disk" and the line printed the path, on a
1194
+ // command that takes `--atlas-in` like `build` does — so an explain of a
1195
+ // pack build named a file it never opened. Same renderer as the other two
1196
+ // printers now, for the same reason they share one (issue #671).
1197
+ console.log('\ndropped states (listed in the manifest, no art behind them)');
1198
+ for (const d of result.droppedStates) console.log(` ${d.slot}/${d.state} ${droppedStateReason(d)}`);
1199
+ }
1200
+
1201
+ console.log('\nmix table (player config, not skeleton JSON)');
1202
+ console.log(` default=${motion.mix?.default ?? 0} pairs=${JSON.stringify(motion.mix?.pairs ?? [])}`);
1203
+ }
1204
+
1205
+ /**
1206
+ * ingest — a skeleton back into the two specs that rebuild it.
1207
+ *
1208
+ * The only command that runs against `build`'s direction, and the contract is an
1209
+ * equality rather than a rulebook: `build(ingest(A))` is `A`. Everything it
1210
+ * cannot carry is a **finding** printed here with its code, because those lines
1211
+ * are what tells an author what the rebuilt rig will not have — they are this
1212
+ * command's whole UI, exactly as the validator's messages are `build`'s.
1213
+ *
1214
+ * ⚠️ It writes both specs even when a blocker was found, and then exits
1215
+ * non-zero: a blocker means the rebuild will not be the file that was read, and
1216
+ * the useful thing at that point is the spec plus the list of what is missing
1217
+ * from it. `build`'s "nothing written" rule is not this rule — that one is about
1218
+ * a **gated artifact** on disk, and these two files are inputs to the gate
1219
+ * rather than output of it.
1220
+ */
1221
+ export function cmdIngest(flags: Record<string, string>, positional: string[]): void {
1222
+ const [source] = positional;
1223
+ if (!source) throw new UsageError('ingest takes one path: <skeleton.json>');
1224
+ const skeletonPath = resolve(source);
1225
+ if (!existsSync(skeletonPath)) throw new UsageError(`nothing at ${skeletonPath}`);
1226
+ // The same sentence `resolveArtifacts` refuses a `.spine` with, for the same
1227
+ // reason (docs/INGEST.md §5): this reads Spine 4.3 skeleton JSON and nothing
1228
+ // else — not a project file, not a binary `.skel`, not the atlas.
1229
+ if (!skeletonPath.endsWith('.json')) {
1230
+ throw new UsageError(
1231
+ `${skeletonPath} is not a .json skeleton — ingest reads Spine 4.3 skeleton JSON and nothing else: not a ` +
1232
+ '.spine project, not a binary .skel, not an atlas. Re-export as JSON',
1233
+ );
1234
+ }
1235
+ if (flags.out === undefined) throw new UsageError('ingest needs --out <dir> — the directory to write rig.json and motion.json into');
1236
+ const art = flags.art ?? 'loose';
1237
+ if (art !== 'loose' && art !== 'none') {
1238
+ throw new UsageError(
1239
+ `--art is ${JSON.stringify(art)}; it is "loose" (name an image per attachment, for \`build --images <dir>\`) ` +
1240
+ 'or "none" (state width/height only, for `build --atlas-in <pack>`). A skeleton encodes neither, which is ' +
1241
+ 'why this is a flag',
1242
+ );
1243
+ }
1244
+ let stage: IngestStage | undefined;
1245
+ if (flags.stage !== undefined) {
1246
+ const parts = flags.stage.split(',').map(Number);
1247
+ if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
1248
+ throw new UsageError(`--stage is ${JSON.stringify(flags.stage)}; give four numbers, x,y,width,height`);
1249
+ }
1250
+ stage = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
1251
+ }
1252
+ const outDir = resolve(flags.out);
1253
+ /**
1254
+ * The rig spec's own `images`, spelled from `--out` the way `build` spells
1255
+ * `skeleton.images` from its own output directory — the SAME function, so the
1256
+ * two conventions cannot drift (issue #595). Without it the field is left out,
1257
+ * an `image` name resolves against the spec's own directory, and every rebuild
1258
+ * of the spec has to carry `build --images <dir>`.
1259
+ */
1260
+ let specImages: string | undefined;
1261
+ if (flags.images !== undefined) {
1262
+ // Refused rather than ignored, for the reason `chainfit` refuses `--atlas`:
1263
+ // a flag that silently does nothing is worse than one that says why it
1264
+ // cannot. Measured — an `--art none` spec rebuilt with `--images` naming a
1265
+ // directory that does not exist is byte-identical to one rebuilt without
1266
+ // it, because no attachment carries an `image` for that directory to be the
1267
+ // base of.
1268
+ if (art === 'none') {
1269
+ throw new UsageError(
1270
+ '--images <dir> and --art none contradict: `none` writes width/height and no `image` at all, so the rig ' +
1271
+ "spec's `images` directory would be the base of nothing and no rebuild would read it. Use --art loose to " +
1272
+ 'name an image per attachment, or drop --images — an `--art none` spec rebuilds with `build --atlas-in ' +
1273
+ '<pack.atlas>`',
1274
+ );
1275
+ }
1276
+ // Not checked for existence, deliberately: `ingest` reads the skeleton and
1277
+ // nothing else, so the parts may well be extracted AFTER the specs are
1278
+ // written, and refusing a directory this command never opens would refuse a
1279
+ // legitimate order of work. `build` is where a missing PNG is named.
1280
+ specImages = relativeImagesPath(outDir, resolve(flags.images));
1281
+ }
1282
+ console.log(`rigc ingest ${skeletonPath}`);
1283
+ console.log(` .. out ${outDir}`);
1284
+ console.log(` .. art ${art}`);
1285
+ if (specImages !== undefined) console.log(` .. images ${specImages} (the rig spec's own, from ${outDir})`);
1286
+
1287
+ /**
1288
+ * What the run has to report, however the parse went.
1289
+ *
1290
+ * 🔒 A spec the tree's own parser refuses is a **finding**, not an escape
1291
+ * (issue #692): the three files are written, the coded `BLOCK` line is
1292
+ * printed, and the exit code comes off the findings like every other run's.
1293
+ * The two specs are read as `unknown` because that is all this function does
1294
+ * with them — `JSON.stringify` — and a cast to `RigSpec` here would be this
1295
+ * file claiming a parse that did not happen.
1296
+ */
1297
+ // A rigc build's stage is the model document's beside it, when that document is this skeleton's (issue #907): its header is the
1298
+ // setup-pose bounding box. An export, or a skeleton with no such document, is read off its header.
1299
+ const documentStage = documentStageBeside(skeletonPath, readFileSync(skeletonPath, 'utf8'));
1300
+ if (documentStage !== undefined) {
1301
+ console.log(` .. stage ${documentStage === null ? 'none declared' : `${documentStage.width} x ${documentStage.height}`} — read from the ${MODEL_DOCUMENT_FILE} beside it (its spine.sha256 is this skeleton's); the header's box is the setup-pose bounding box`);
1302
+ }
1303
+ if (flags['stage-box'] !== undefined) console.log(` .. stage read from the bounding box in slot "${flags['stage-box']}" (--stage-box); the header's box is the setup-pose bounding box`);
1304
+ let result: { rig: unknown; motion: unknown; findings: IngestFinding[] };
1305
+ try {
1306
+ result = ingest(readJsonFile(skeletonPath), {
1307
+ ...(documentStage === undefined ? {} : { documentStage }),
1308
+ name: flags.name ?? basename(skeletonPath, '.json'),
1309
+ art,
1310
+ images: specImages,
1311
+ stage,
1312
+ ...(flags['stage-box'] === undefined ? {} : { stageBox: flags['stage-box'] }),
1313
+ source: basename(skeletonPath),
1314
+ version: readVersion(),
1315
+ });
1316
+ } catch (err) {
1317
+ if (!(err instanceof IngestSpecRefused)) throw err;
1318
+ result = { rig: err.rig, motion: err.motion, findings: err.findings };
1319
+ }
1320
+
1321
+ mkdirSync(outDir, { recursive: true });
1322
+ // Indent 2, which is what `compile` writes the skeleton with. One emitter
1323
+ // convention, so a spec and the skeleton it came from read the same way.
1324
+ writeFileSync(join(outDir, 'rig.json'), `${JSON.stringify(result.rig, null, 2)}\n`);
1325
+ writeFileSync(join(outDir, 'motion.json'), `${JSON.stringify(result.motion, null, 2)}\n`);
1326
+ writeFileSync(join(outDir, 'findings.json'), `${JSON.stringify(result.findings, null, 2)}\n`);
1327
+
1328
+ // Grouped by kind rather than printed in discovery order: a blocker is what
1329
+ // decides the exit code, and a reader scanning for one should not have to
1330
+ // read past a hundred DURATION lines to find it.
1331
+ // The gutter words are `INGEST_GUTTERS`, which is also the column
1332
+ // `docs/INGEST.md` §2.0's finding-code table is keyed on (issue #675); the
1333
+ // pad to one width is this printer's, so the codes line up.
1334
+ const width = Math.max(...Object.values(INGEST_GUTTERS).map((gutter) => gutter.length));
1335
+ for (const kind of ['blocker', 'judgement', 'lossy'] as const) {
1336
+ for (const finding of result.findings.filter((f) => f.kind === kind)) {
1337
+ console.log(` ${INGEST_GUTTERS[kind].padEnd(width)} ${finding.code}: ${finding.where} — ${finding.detail}`);
1338
+ }
1339
+ }
1340
+ console.log(`rigc: wrote ${join(outDir, 'rig.json')}`);
1341
+ console.log(`rigc: wrote ${join(outDir, 'motion.json')}`);
1342
+ console.log(`rigc: wrote ${join(outDir, 'findings.json')}`);
1343
+ // The hint is the command the caller will actually run, so it drops `--images`
1344
+ // exactly when the spec now carries the directory itself — a hint that asks for
1345
+ // a flag the spec made unnecessary is the defect issue #595 is about, printed.
1346
+ const artFlag = art === 'none' ? ' --atlas-in <pack.atlas>' : specImages === undefined ? ' --images <dir>' : '';
1347
+ console.log(
1348
+ `rigc: build it with rigc build --rig ${join(outDir, 'rig.json')} --motion ${join(outDir, 'motion.json')}` +
1349
+ `${artFlag} --out <dir>`,
1350
+ );
1351
+
1352
+ const blockers = result.findings.filter((f) => f.kind === 'blocker');
1353
+ if (blockers.length > 0) {
1354
+ console.error(
1355
+ `rigc: ${blockers.length} blocker(s) — both specs were written, and a build from them will NOT be the ` +
1356
+ `skeleton that was read (${[...new Set(blockers.map((f) => f.code))].join(', ')})`,
1357
+ );
1358
+ process.exit(1);
1359
+ }
1360
+ }
1361
+
1362
+ /** What `rigc skills` offers. One today; the list is what the refusal of any other word prints. */
1363
+ const SKILLS_SUBCOMMANDS = ['install'];
1364
+
1365
+ type SkillsInstallAction = 'linked' | 'copied' | 'already linked' | 'already copied';
1366
+
1367
+ interface SkillsInstallEntry {
1368
+ /** `<dir>/<name>`. */
1369
+ target: string;
1370
+ /** `<package>/skills/<name>`. */
1371
+ source: string;
1372
+ action: SkillsInstallAction;
1373
+ /** The link text, relative to the directory it sits in, when the entry is a link. */
1374
+ link: string;
1375
+ }
1376
+
1377
+ /** Every `<name>/` under `source` that holds a `SKILL.md`, in name order, so two runs print the same lines. */
1378
+ function shippedSkills(source: string): string[] {
1379
+ if (!existsSync(source) || !statSync(source).isDirectory()) return [];
1380
+ return readdirSync(source)
1381
+ .filter((name) => statSync(join(source, name)).isDirectory() && existsSync(join(source, name, 'SKILL.md')))
1382
+ .sort();
1383
+ }
1384
+
1385
+ /**
1386
+ * The real path of `path` whether or not it exists yet: the real path of its
1387
+ * nearest existing ancestor with the rest appended. A relative link has to be
1388
+ * computed between two paths spelled the same way, and on macOS the temp
1389
+ * directory alone is reached as `/var/…` and is really `/private/var/…`.
1390
+ */
1391
+ function realpathAhead(path: string): string {
1392
+ const rest: string[] = [];
1393
+ let at = resolve(path);
1394
+ while (!existsSync(at)) {
1395
+ const up = dirname(at);
1396
+ if (up === at) break;
1397
+ rest.unshift(basename(at));
1398
+ at = up;
1399
+ }
1400
+ return join(realpathSync(at), ...rest);
1401
+ }
1402
+
1403
+ /** Every file under `root`, relative and sorted, so two trees compare in one order. */
1404
+ function filesUnder(root: string, prefix = ''): string[] {
1405
+ const out: string[] = [];
1406
+ for (const name of readdirSync(join(root, prefix)).sort()) {
1407
+ const rel = prefix === '' ? name : `${prefix}/${name}`;
1408
+ if (lstatSync(join(root, rel)).isDirectory()) out.push(...filesUnder(root, rel));
1409
+ else out.push(rel);
1410
+ }
1411
+ return out;
1412
+ }
1413
+
1414
+ /** The first way `copy` differs from `original`, or null when every file is the same bytes. */
1415
+ function firstDifference(copy: string, original: string): string | null {
1416
+ const theirs = filesUnder(copy);
1417
+ const ours = filesUnder(original);
1418
+ for (const rel of ours) {
1419
+ if (!theirs.includes(rel)) return `${rel} is missing from it`;
1420
+ if (!readFileSync(join(copy, rel)).equals(readFileSync(join(original, rel)))) return `${rel} differs`;
1421
+ }
1422
+ for (const rel of theirs) if (!ours.includes(rel)) return `${rel} is in it and not in the package`;
1423
+ return null;
1424
+ }
1425
+
1426
+ /**
1427
+ * Install every shipped skill into `dir`, or refuse and write nothing.
1428
+ *
1429
+ * The plan is made in full before the first write: every entry is classified as
1430
+ * absent, already this command's, or in the way, and one entry in the way
1431
+ * refuses the whole call with every such entry named.
1432
+ */
1433
+ function installSkills(source: string, dir: string, copy: boolean): SkillsInstallEntry[] {
1434
+ const names = shippedSkills(source);
1435
+ if (names.length === 0) {
1436
+ throw new SkillsInstallError(
1437
+ `no skill to install: ${source} ${existsSync(source) ? 'holds no <name>/SKILL.md' : 'is not there'}, and it is ` +
1438
+ 'the skills/ directory of the package this command ran from; nothing was written',
1439
+ );
1440
+ }
1441
+ if (existsSync(dir) && !statSync(dir).isDirectory()) {
1442
+ throw new SkillsInstallError(`${dir} exists and is not a directory, so no skill can be installed into it; nothing was written`);
1443
+ }
1444
+ const realDir = realpathAhead(dir);
1445
+ const planned: SkillsInstallEntry[] = [];
1446
+ const refused: string[] = [];
1447
+ for (const name of names) {
1448
+ const target = join(dir, name);
1449
+ const from = join(source, name);
1450
+ const realFrom = realpathSync(from);
1451
+ const link = relative(realDir, realFrom);
1452
+ let action: SkillsInstallAction = copy ? 'copied' : 'linked';
1453
+ let found: string | null = null;
1454
+ const stat = existsSync(target) || isLink(target) ? lstatSync(target) : null;
1455
+ if (stat === null) {
1456
+ // absent: this command writes it
1457
+ } else if (stat.isSymbolicLink()) {
1458
+ const text = readlinkSync(target);
1459
+ const pointsAt = resolve(realDir, text);
1460
+ const lands = existsSync(pointsAt) ? realpathSync(pointsAt) : null;
1461
+ if (lands === realFrom && !copy) action = 'already linked';
1462
+ else if (lands === realFrom) found = `a symlink to ${text}, the package's own folder, and --copy asks for a directory in its place`;
1463
+ else found = `a symlink to ${text}, ${lands === null ? 'which resolves to nothing' : `which resolves to ${lands}`}`;
1464
+ } else if (stat.isDirectory()) {
1465
+ const difference = copy ? firstDifference(target, from) : null;
1466
+ if (!copy) found = 'a directory';
1467
+ else if (difference === null) action = 'already copied';
1468
+ else found = `a directory that is not the package's copy (${difference})`;
1469
+ } else {
1470
+ found = 'a plain file';
1471
+ }
1472
+ if (found !== null) refused.push(`${target} is ${found}; ${copy ? `a copy of ${from}` : `a symlink to ${link}`} was required`);
1473
+ else planned.push({ target, source: from, action, link });
1474
+ }
1475
+ if (refused.length > 0) {
1476
+ throw new SkillsInstallError(
1477
+ `${refused.length} of the ${names.length} skill(s) cannot be installed into ${dir}, and nothing was written:\n` +
1478
+ refused.map((line) => ` ${line}`).join('\n') +
1479
+ '\nRemove the entries named above, or pass --dir to install somewhere else.',
1480
+ );
1481
+ }
1482
+ mkdirSync(dir, { recursive: true });
1483
+ for (const entry of planned) {
1484
+ if (entry.action === 'linked') symlinkSync(entry.link, entry.target, 'dir');
1485
+ else if (entry.action === 'copied') cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true, force: false });
1486
+ }
1487
+ return planned;
1488
+ }
1489
+
1490
+ /** A dangling link is not `existsSync`, and is still an entry in the way. */
1491
+ function isLink(path: string): boolean {
1492
+ try {
1493
+ return lstatSync(path).isSymbolicLink();
1494
+ } catch {
1495
+ return false;
1496
+ }
1497
+ }
1498
+
1499
+ export function cmdSkills(flags: Record<string, string>, positional: string[]): void {
1500
+ const [sub, ...extra] = positional;
1501
+ if (sub === undefined) {
1502
+ throw new UsageError(
1503
+ `skills takes a subcommand: ${SKILLS_SUBCOMMANDS.join(', ')} — \`rigc skills install\` links every skill this ` +
1504
+ `package ships into ${DEFAULT_SKILLS_DIR}`,
1505
+ );
1506
+ }
1507
+ if (!SKILLS_SUBCOMMANDS.includes(sub)) {
1508
+ throw new UsageError(`unknown skills subcommand: ${sub} (rigc skills offers ${SKILLS_SUBCOMMANDS.join(', ')})`);
1509
+ }
1510
+ if (extra.length > 0) {
1511
+ throw new UsageError(
1512
+ `skills install takes no positional argument, and ${JSON.stringify(extra[0])} was given — the directory is --dir <path>`,
1513
+ );
1514
+ }
1515
+ const takes = COMMANDS.find((c) => c.name === 'skills')?.flags ?? [];
1516
+ const foreign = Object.keys(flags).filter((flag) => !takes.includes(flag));
1517
+ if (foreign.length > 0) {
1518
+ throw new UsageError(
1519
+ `skills install takes ${takes.map((flag) => `--${flag}`).join(' and ')}; ` +
1520
+ `${foreign.map((flag) => `--${flag}`).join(', ')} is not one of them`,
1521
+ );
1522
+ }
1523
+ const copy = flags.copy !== undefined;
1524
+ const dir = resolve(process.cwd(), flags.dir ?? DEFAULT_SKILLS_DIR);
1525
+ const entries = installSkills(join(PACKAGE_ROOT, 'skills'), dir, copy);
1526
+ for (const entry of entries) {
1527
+ const ends = entry.action.endsWith('linked') ? `${entry.target} -> ${entry.link} (${entry.source})` : `${entry.target} <- ${entry.source}`;
1528
+ console.log(` ${entry.action.padEnd(14)} ${ends}`);
1529
+ }
1530
+ const wrote = entries.filter((entry) => entry.action === 'linked' || entry.action === 'copied').length;
1531
+ const verb = copy ? 'copied' : 'linked';
1532
+ console.log(
1533
+ wrote === 0
1534
+ ? `rigc skills install: nothing to do — all ${entries.length} skill(s) are already ${verb} into ${dir}`
1535
+ : `rigc skills install: ${wrote} of ${entries.length} skill(s) ${verb} into ${dir}` +
1536
+ (wrote < entries.length ? `, ${entries.length - wrote} already there` : ''),
1537
+ );
1538
+ }
1539
+
1540
+ // ---------------------------------------------------------------------------
1541
+ // build — the second entry's gate (issue #1060, step 4e of #380)
1542
+ // ---------------------------------------------------------------------------
1543
+
1544
+ /**
1545
+ * The gate `cli_core.ts build` runs (`BuildGate`), where `cli.ts build` runs
1546
+ * the round trip: the model side over the document (`validateModel`), and the
1547
+ * round trip's own rules restated over the text the emitter wrote
1548
+ * (`validateEmittedText`) — A18 among them, comparing a second, independent
1549
+ * compile's skeleton, atlas and document byte for byte — with A00, spine-core's
1550
+ * parse, a SKIP naming it. One report, printed by the printer `cli.ts build`
1551
+ * prints with, and a line saying which rules ran here and which did not.
1552
+ *
1553
+ * ⚠️ The document the model side reads is the one this gate judges: spelled
1554
+ * from the atlas text the gate is handed, so under `--copy-images` it names
1555
+ * the pages where the compile measured them — the files the round trip's own
1556
+ * A17 reads on `cli.ts` — and A18 compares the documents as written, the
1557
+ * copies' names in them. Under `--pack` the two are one text.
1558
+ *
1559
+ * Exported for the selftest's `RPK` plants (issue #1169), which run `repack`
1560
+ * through this entry's dispatch with a hook between the lift and the build.
1561
+ */
1562
+ export const MODEL_AND_TEXT_GATE: BuildGate = {
1563
+ supplier: 'model',
1564
+ heading: (profile) =>
1565
+ ` .. validate (the model side over the document + the round trip's rules restated over the emitted text, profile ${profile}; this entry links no spine-core, so the round trip does not run)`,
1566
+ run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
1567
+ const model = validateModel({ modelText: modelDocument(result.model, result.skeletonText, atlasText), atlasDir, profile });
1568
+ const text = validateEmittedText({ skeletonText: result.skeletonText, atlasText, modelText, reEmit, profile });
1569
+ const report = {
1570
+ failures: [...model.failures, ...text.failures],
1571
+ passed: [...model.passed, ...text.passed],
1572
+ skipped: [...model.skipped, ...text.skipped],
1573
+ profileSkipped: [...model.profileSkipped, ...text.profileSkipped],
1574
+ // The emitted text's figures first: they are the pair's (`pages` … `version`), which `validate()` writes
1575
+ // before any other, and the model side's follow in its order — one line, keyed and ordered as `cli.ts build`'s (issue #1114, `RC39`).
1576
+ stats: { ...text.stats, ...model.stats },
1577
+ profile,
1578
+ };
1579
+ for (const line of reportLines(report)) console.log(line);
1580
+ console.log(
1581
+ ` .. ${Object.entries(report.stats)
1582
+ .map(([k, v]) => `${k}=${v}`)
1583
+ .join(' ')}`,
1584
+ );
1585
+ const ran = (r: { passed: string[]; failures: Array<{ assertion: string }>; skipped: Array<{ assertion: string }>; profileSkipped: Array<{ assertion: string }> }): number =>
1586
+ new Set([...r.passed, ...r.failures.map((f) => f.assertion), ...r.skipped.map((x) => x.assertion), ...r.profileSkipped.map((x) => x.assertion)]).size;
1587
+ const notRun = text.skipped.filter((x) => x.assertion === 'A00_ROUNDTRIP_PARSE').map((x) => x.assertion);
1588
+ // The line is spelled from the values the `--report` document carries as the gate's `here` (issue #1213).
1589
+ const here = { modelSide: ran(model), restated: ran(text) - notRun.length, notRun };
1590
+ console.log(
1591
+ ` .. here: ${here.modelSide} rule(s) on the model side over the document, ${here.restated} of the round trip's own restated over the emitted text; ` +
1592
+ `not run: ${here.notRun.join(', ') || 'none'} — spine-core's parse, which only the entry that links spine-core runs, on build and validate: ` +
1593
+ `installed, the same \`rigc\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts\``,
1594
+ );
1595
+ return { report, here };
1596
+ },
1597
+ look: (outDir) => `rigc: look at it: rigc render --candidate ${outDir}`,
1598
+ };
1599
+
1600
+ /**
1601
+ * The bodies the second entry runs under a name whose full-entry body is the
1602
+ * runtime's (`runtime.core` in `COMMANDS`, issue #1060) — registered by
1603
+ * `cli_core.ts` alone, beside `CORE_COMMAND_RUNS`. `build` is `runBuild` with
1604
+ * this entry's gate: it writes what `cli.ts build` writes. `repack` (issue
1605
+ * #1169) is `./repack.ts`'s body with the same gate, for the same reason: it
1606
+ * is `build --pack` run over a build's own lifted regions.
1607
+ */
1608
+ export const CORE_ENTRY_RUNS: Readonly<Record<string, CommandRun>> = {
1609
+ build: ({ flags }) => runBuild(flags, MODEL_AND_TEXT_GATE),
1610
+ repack: (args) => cmdRepack(args, MODEL_AND_TEXT_GATE),
1611
+ };
1612
+
1613
+ /**
1614
+ * The bodies of every command whose `runtime` is `false` (`COMMANDS`), by
1615
+ * name — what both entries register. Nothing this module imports links
1616
+ * spine-core; `RC25` in `selftest.ts` follows its imports off the disk.
1617
+ */
1618
+ export const CORE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
1619
+ explain: ({ flags }) => cmdExplain(flags),
1620
+ ingest: ({ flags, positional }) => cmdIngest(flags, positional),
1621
+ diff: ({ flags, lists, positional }) => cmdDiff(flags, lists, positional),
1622
+ check: ({ flags }) => cmdCheck(flags),
1623
+ render: ({ flags }) => cmdRender(flags),
1624
+ pose: ({ flags }) => cmdPose(flags),
1625
+ chainfit: ({ flags }) => cmdChainFit(flags),
1626
+ skills: ({ flags, positional }) => cmdSkills(flags, positional),
1627
+ };