spine-rigc 1.9.2 → 1.9.3

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