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
package/cli.ts CHANGED
@@ -31,4400 +31,31 @@
31
31
  * Its paths resolve against the cuts.json file itself, so the table travels
32
32
  * with the project that owns the art rather than with this repository.
33
33
  */
34
- import {
35
- appendFileSync,
36
- cpSync,
37
- existsSync,
38
- lstatSync,
39
- mkdirSync,
40
- readdirSync,
41
- readFileSync,
42
- readlinkSync,
43
- realpathSync,
44
- rmSync,
45
- statSync,
46
- symlinkSync,
47
- writeFileSync,
48
- } from 'node:fs';
49
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
50
- import {
51
- BallotError,
52
- buildBallot,
53
- ledgerLineText,
54
- MAX_CANDIDATES,
55
- MIN_CANDIDATES,
56
- parseLedger,
57
- readBallotManifest,
58
- resultFilename,
59
- TIE,
60
- verifyResult,
61
- VOTE_RULES,
62
- type BallotCandidateInput,
63
- type BallotInput,
64
- } from './src/ballot.ts';
65
- import {
66
- boneDistance,
67
- BONEDIST_SPEC,
68
- boneDistLines,
69
- BoneDistError,
70
- IDENTITY_CORRESPONDENCE,
71
- type BoneDistReport,
72
- } from './src/bonedist.ts';
73
- import {
74
- checkAgainstFrames,
75
- checkLines,
76
- CheckError,
77
- CheckPlates,
78
- type CheckOptions,
79
- type CheckReport,
80
- } from './src/check.ts';
81
- import { writeCheckPictures } from './src/checkpics.ts';
82
- import { compile, CompileError, droppedStateReason, relativeImagesPath, type CompileOptions } from './src/compile.ts';
83
- import { MODEL_DOCUMENT_FILE, modelDocument } from './src/model.ts';
84
- import { deformReportBlock } from './src/deformreport.ts';
85
- import { surveyOfBuild, type DeformSurvey } from './src/deformmeasure.ts';
86
- import { CoreInputError } from './src/core/index.ts';
87
- import {
88
- diffLines,
89
- diffSkeletons,
90
- reportedFigures,
91
- sectionFigures,
92
- type DiffAnimationPair,
93
- type DiffReport,
94
- } from './src/diff.ts';
95
- import { ingest, IngestError, IngestSpecRefused, INGEST_GUTTERS, type IngestFinding, type IngestStage } from './src/ingest.ts';
96
- import { NotAPngError } from './src/png.ts';
97
- import { copyAtlasPages, plannedPageCopies } from './src/emit.ts';
98
- import {
99
- DEFAULT_PADDING,
100
- DEFAULT_PAGE_EDGES,
101
- DEFAULT_PAGE_SIZE,
102
- PAGE_EDGES,
103
- packAtlas,
104
- type PageEdges,
105
- pageFootprint,
106
- parseAtlasText,
107
- type AtlasRegion,
108
- } from './src/atlas.ts';
109
- import { parseJsonWithPosition } from './src/json-position.ts';
110
- import { PARSER_DEFAULTS, parserReading } from './src/keyorder.ts';
111
- import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
112
- import {
113
- DEFAULT_MAX_RESIDUAL,
114
- DEFAULT_SCALE_MAX,
115
- DEFAULT_SCALE_MIN,
116
- estimatePose,
117
- PoseError,
118
- poseLines,
119
- type PoseOptions,
120
- } from './src/pose.ts';
121
- import {
122
- ANCHOR_MAX_RESIDUAL,
123
- ANCHOR_MAX_UNEXPLAINED,
124
- chainFitLines,
125
- ChainFitError,
126
- DEFAULT_HINGE_MAX,
127
- DEFAULT_HINGE_MIN,
128
- DEFAULT_MIN_LEVER_PX,
129
- DEFAULT_MIN_VISIBLE,
130
- DEFAULT_PASSES,
131
- estimateChainFit,
132
- type ChainFitOptions,
133
- } from './src/chainfit.ts';
134
- import { buildPreview, buildPreviewPanes, PLAYER_LINE, type PreviewGate, type PreviewInput, type PreviewPage } from './src/preview.ts';
135
- import {
136
- atlasPageNames,
137
- BACKGROUND,
138
- contactSheet,
139
- FRAMES_SIDECAR,
140
- FRAMES_SPEC,
141
- framingViewport,
142
- GEOMETRY_FILE,
143
- GeometryError,
144
- UnframeablePoseError,
145
- geometryFileOf,
146
- geometryText,
147
- loadCandidate,
148
- PROTOCOL_FPS,
149
- renderFrame,
150
- sampleAll,
151
- sampleAnimation,
152
- SETUP_POSE_DIR,
153
- SHEET_FILE,
154
- SHEET_TILE,
155
- sidecarViewport,
156
- SlotSubsetError,
157
- POSER_NAMES,
158
- PoserChoiceError,
159
- refuseUnchosen,
160
- CandidateAtlasError,
161
- CandidatePairError,
162
- SpineRuntimeError,
163
- throughPoser,
164
- type Frame,
165
- type FramesSidecar,
166
- type FrameSet,
167
- type PoserName,
168
- type SkeletonFacts,
169
- type SlotSubset,
170
- } from './src/render.ts';
171
- import {
172
- assertionCountForProfile,
173
- attachmentRegionJoins,
174
- CLI_DEFAULT_PROFILE,
175
- reportLines,
176
- validate,
177
- VALIDATE_PROFILES,
178
- type ValidateProfile,
179
- } from './src/validate.ts';
180
- import { parseMotionSpec } from './src/motion.ts';
181
- import { depthStepLevels, type FoldLimit, type TurnCeiling } from './src/depth.ts';
182
- import type { CompileResult, DroppedState } from './src/types.ts';
183
-
184
- /**
185
- * One entry of a cuts.json, every path relative to the cuts.json file.
186
- *
187
- * `rig` is required — it is the skeleton's structure, and until it was a file
188
- * that structure was three hard-coded tables in the compiler. `manifest` is
189
- * optional: a skeleton with no measured art behind it has none, and then the rig
190
- * spec carries its own attachments and stage size.
191
- */
192
- export interface CutEntry {
193
- rig: string;
194
- motion: string;
195
- out: string;
196
- manifest?: string;
197
- /** Base directory for the rig spec's `image` references, if not the rig's own. */
198
- images?: string;
199
- }
200
-
201
- export type CutTable = Record<string, CutEntry>;
202
-
203
- class UsageError extends Error {}
204
-
205
- /**
206
- * `explain` refusing a pair it cannot pose — a usage error in kind, printed
207
- * without the usage block for `PoseError`'s and `IngestError`'s reason: the
208
- * message names an attachment, a region and two flags, and reprinting every
209
- * command's usage under it buries the one line that says what to change.
210
- */
211
- class ExplainError extends Error {}
212
-
213
- // ---------------------------------------------------------------------------
214
- // package metadata — the installed version and repository, for `--version`
215
- // and for naming a remedy `bench` can only give from a repo checkout.
216
- // ---------------------------------------------------------------------------
217
-
218
- interface PackageMeta {
219
- version?: string;
220
- repository?: string | { url?: string };
221
- }
222
-
223
- let packageMeta: PackageMeta | null | undefined;
224
-
225
- /** `package.json` sits next to this file both in the repo and once installed. */
226
- function readPackageMeta(): PackageMeta | null {
227
- if (packageMeta === undefined) {
228
- try {
229
- packageMeta = JSON.parse(readFileSync(join(import.meta.dir, 'package.json'), 'utf8')) as PackageMeta;
230
- } catch {
231
- packageMeta = null;
232
- }
233
- }
234
- return packageMeta;
235
- }
236
-
237
- function readVersion(): string {
238
- return readPackageMeta()?.version ?? 'unknown';
239
- }
240
-
241
- function repositoryUrl(): string {
242
- const repo = readPackageMeta()?.repository;
243
- const url = typeof repo === 'string' ? repo : repo?.url;
244
- return (url ?? 'https://github.com/firejune/rigc').replace(/^git\+/, '').replace(/\.git$/, '');
245
- }
246
-
247
- // ---------------------------------------------------------------------------
248
- // argument parsing
249
- // ---------------------------------------------------------------------------
250
-
251
- /**
252
- * The flags that are switches rather than `--flag value` pairs.
253
- *
254
- * Listed by name rather than inferred from "the next argument looks like a
255
- * flag": inferring it would turn `--out --json report.json` — a real typo, a
256
- * missing value — into a silently accepted switch plus a stray positional.
257
- *
258
- * ⚠️ This set and `FLAG_VALUES` are two halves of one statement, and they are
259
- * the halves a reader and the parser read separately: a flag absent from
260
- * `FLAG_VALUES` is printed bare in every usage line and flag table, and a flag
261
- * present here is the only kind the parser will accept bare. `all-bones` was in
262
- * one half and not the other for two releases — documented bare in `bonedist`'s
263
- * usage line, in the shared flag table, and in the hint `src/bonedist.ts` prints
264
- * under a truncated bone table, while the parser fell through to the value
265
- * branch and answered the caller who followed that hint with `rigc: --all-bones
266
- * needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
267
- * halves together by reading `--help` rather than by naming a flag.
268
- */
269
- const BOOLEAN_FLAGS = new Set(['all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry']);
270
-
271
- /**
272
- * The flags a command is allowed to spell more than once.
273
- *
274
- * `vote --candidate` is, because a ballot is *by definition* several
275
- * candidates; `preview --candidate` is, because a pane per candidate on one
276
- * page is what an agent otherwise builds by hand (issue #837); and `diff --as`
277
- * is, because a skeleton has as many shots as it has and one pairing per flag is the only spelling that keeps each pair a pair
278
- * (issue #720). Everywhere else a repeat is a mistake and is refused: `check
279
- * --candidate a --candidate b` used to take `b` silently, which is a report
280
- * about a rig the caller did not think they were asking about.
281
- */
282
- const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
283
- vote: new Set(['candidate']),
284
- diff: new Set(['as']),
285
- preview: new Set(['candidate']),
286
- };
287
-
288
- /**
289
- * `--flag value` pairs plus the leftover positionals, in order.
290
- *
291
- * `lists` carries every occurrence of every flag and `flags` carries the last
292
- * one, so a command that wants a repeated flag reads `lists` and the ones that
293
- * do not are untouched by the addition.
294
- */
295
- function parseArgs(
296
- argv: string[],
297
- repeatable: ReadonlySet<string> = new Set(),
298
- ): { flags: Record<string, string>; lists: Record<string, string[]>; positional: string[] } {
299
- const flags: Record<string, string> = {};
300
- const lists: Record<string, string[]> = {};
301
- const positional: string[] = [];
302
- const take = (name: string, value: string): void => {
303
- if (flags[name] !== undefined && !repeatable.has(name)) {
304
- throw new UsageError(
305
- `--${name} was given more than once (${JSON.stringify(flags[name])} then ${JSON.stringify(value)}); ` +
306
- 'this command takes it once',
307
- );
308
- }
309
- flags[name] = value;
310
- (lists[name] ??= []).push(value);
311
- };
312
- for (let i = 0; i < argv.length; i++) {
313
- const arg = argv[i];
314
- if (arg.startsWith('--')) {
315
- const eq = arg.indexOf('=');
316
- if (eq !== -1) {
317
- take(arg.slice(2, eq), arg.slice(eq + 1));
318
- } else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
319
- take(arg.slice(2), 'true');
320
- } else {
321
- const next = argv[i + 1];
322
- if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
323
- take(arg.slice(2), next);
324
- i++;
325
- }
326
- } else {
327
- positional.push(arg);
328
- }
329
- }
330
- return { flags, lists, positional };
331
- }
332
-
333
- /**
334
- * Read and parse a JSON file the caller named on the command line — a cuts
335
- * table, a candidate or reference skeleton to `diff`. A parse failure names the
336
- * file and, best-effort, where inside it the syntax broke (see
337
- * `parseJsonWithPosition`); left as a raw `JSON.parse`, it would surface as an
338
- * unhandled `SyntaxError` with a stack trace instead of a usage error.
339
- */
340
- function readJsonFile(path: string): unknown {
341
- return parseJsonNamed(readFileSync(path, 'utf8'), path);
342
- }
343
-
344
- /** `text`, read from `path`, parsed — and refused naming the file and where it broke when it is not JSON. */
345
- function parseJsonNamed(text: string, path: string): unknown {
346
- try {
347
- return parseJsonWithPosition(text);
348
- } catch (err) {
349
- throw new UsageError(`cannot read ${path}: ${(err as Error).message}`);
350
- }
351
- }
352
-
353
- /**
354
- * A skeleton a command was pointed at, as text — refused like every other JSON
355
- * file on the command line (`parseJsonNamed`) when it is not JSON (issue
356
- * #1042). `render`, `check`, `bench` and `bonedist` hand the text to a loader
357
- * that parses it again, and on a file that is not JSON that parse surfaced as
358
- * the runtime's `SyntaxError` and a stack, where `diff`, `preview`, `vote` and
359
- * `ingest` already said `cannot read <path>`.
360
- */
361
- function readSkeletonText(path: string): string {
362
- const text = readFileSync(path, 'utf8');
363
- parseJsonNamed(text, path);
364
- return text;
365
- }
366
-
367
- /**
368
- * Read a cuts.json and resolve its three paths against the file's own
369
- * directory. Anchoring on the table rather than on the process cwd is what lets
370
- * the same command work from anywhere in the owning project.
371
- */
372
- function readCutTable(cutsPath: string): { dir: string; table: CutTable } {
373
- const abs = resolve(cutsPath);
374
- if (!existsSync(abs)) throw new UsageError(`no cuts file at ${abs}`);
375
- const parsed = readJsonFile(abs);
376
- if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
377
- throw new UsageError(`${abs}: expected an object of cut name -> { manifest, motion, out }`);
378
- }
379
- return { dir: dirname(abs), table: parsed as CutTable };
380
- }
381
-
382
- function entryToOptions(dir: string, name: string, entry: CutEntry): CompileOptions {
383
- for (const key of ['rig', 'motion', 'out'] as const) {
384
- if (typeof entry?.[key] !== 'string') throw new UsageError(`cut ${JSON.stringify(name)} has no "${key}" path`);
385
- }
386
- const opts: CompileOptions = {
387
- rigPath: resolve(dir, entry.rig),
388
- motionPath: resolve(dir, entry.motion),
389
- outDir: resolve(dir, entry.out),
390
- };
391
- if (entry.manifest !== undefined) opts.manifestPath = resolve(dir, entry.manifest);
392
- if (entry.images !== undefined) opts.imagesDir = resolve(dir, entry.images);
393
- return opts;
394
- }
395
-
396
- /**
397
- * Resolve the cut a command was pointed at, either spelled out on the command
398
- * line or looked up by name in a cuts.json.
399
- */
400
- function resolveCut(flags: Record<string, string>): { label: string; opts: CompileOptions } {
401
- const explicit =
402
- flags.rig !== undefined || flags.manifest !== undefined || flags.motion !== undefined || flags.out !== undefined;
403
- if (explicit) {
404
- if (flags.cut !== undefined || flags.cuts !== undefined) {
405
- throw new UsageError('--rig/--motion/--out and --cut/--cuts are two ways to say the same thing; pick one');
406
- }
407
- for (const key of ['rig', 'motion', 'out'] as const) {
408
- if (flags[key] === undefined) throw new UsageError(`--${key} is required when the cut is spelled out`);
409
- }
410
- const opts: CompileOptions = {
411
- rigPath: resolve(flags.rig),
412
- motionPath: resolve(flags.motion),
413
- outDir: resolve(flags.out),
414
- };
415
- if (flags.manifest !== undefined) opts.manifestPath = resolve(flags.manifest);
416
- if (flags.images !== undefined) opts.imagesDir = resolve(flags.images);
417
- if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
418
- return { label: flags.rig, opts };
419
- }
420
- if (flags.cut === undefined) throw new UsageError('give either --cut <name> --cuts <cuts.json>, or --rig/--motion/--out');
421
- if (flags.cuts === undefined) throw new UsageError('--cut needs --cuts <cuts.json> to look the name up in');
422
- const { dir, table } = readCutTable(flags.cuts);
423
- const entry = table[flags.cut];
424
- if (!entry) {
425
- throw new UsageError(
426
- `unknown cut ${JSON.stringify(flags.cut)} in ${resolve(flags.cuts)}. known: ${Object.keys(table).join(', ') || '(none)'}`,
427
- );
428
- }
429
- const opts = entryToOptions(dir, flags.cut, entry);
430
- // `--atlas-in` is not part of the cuts table: a cut names its rig, motion and
431
- // manifest, and where the pixels are delivered from is a property of the BUILD.
432
- // Resolved against the working directory, like every other path on the command
433
- // line, rather than against the table's directory.
434
- if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
435
- return { label: flags.cut, opts };
436
- }
437
-
438
- // ---------------------------------------------------------------------------
439
- // commands
440
- // ---------------------------------------------------------------------------
441
-
442
- /**
443
- * Read `--profile`, defaulting to `spine` — see `CLI_DEFAULT_PROFILE`.
444
- *
445
- * An unknown name is a usage error rather than a silent fallback, and that
446
- * matters in both directions: a typo used to re-apply the strictest rulebook to
447
- * data the caller was trying to exempt, and it would now drop the policy layer
448
- * from a caller who typed `--profile spine-htlm` and believes they asked for it.
449
- * Neither is something to discover from a green.
450
- */
451
- function readProfile(flags: Record<string, string>): ValidateProfile {
452
- const raw = flags.profile;
453
- if (raw === undefined) return CLI_DEFAULT_PROFILE;
454
- const found = VALIDATE_PROFILES.find((p) => p === raw);
455
- if (!found) throw new UsageError(`--profile ${JSON.stringify(raw)}; known profiles: ${VALIDATE_PROFILES.join(', ')}`);
456
- return found;
457
- }
458
-
459
- /**
460
- * An atlas text to gate INSTEAD of the compile's own, with the second, independent
461
- * emit A18 compares it against.
462
- *
463
- * `--pack` is the only caller. A packed build is gated twice on purpose — once as
464
- * compiled (which is the gate that reads the loose PNGs, so `A06`'s size-vs-file
465
- * clause still measures the art R5 measures) and once as packed (which is the pair
466
- * that actually ships). Handing the second pass its texts rather than re-deriving
467
- * them here keeps `runGate` ignorant of what a pack is.
468
- */
469
- interface AtlasOverride {
470
- text: string;
471
- again: string;
472
- }
473
-
474
- function runGate(
475
- result: CompileResult,
476
- modelText: string,
477
- opts: CompileOptions,
478
- profile: ValidateProfile,
479
- /** The atlas text `build` writes for a compile's own — `--copy-images` renames the pages — which the second compile's document is spelled from, as `modelText` was (issue #1016). */
480
- written: (atlasText: string) => string,
481
- atlas?: AtlasOverride,
482
- ): number {
483
- // The determinism check compares a second, independent compile — its model
484
- // document included, which is the text `build` writes beside the pair, and
485
- // which states where each region sits in the atlas written with it (`pages`,
486
- // issue #1016): so the second document is spelled from the second compile's
487
- // atlas as it would be written, or from the second, independent pack.
488
- const again = compile(opts);
489
- const report = validate({
490
- skeletonText: result.skeletonText,
491
- atlasText: atlas ? atlas.text : result.atlasText,
492
- atlasDir: opts.outDir,
493
- declaredDurations: result.declaredDurations,
494
- modelText,
495
- reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText, atlas ? atlas.again : written(again.atlasText)) },
496
- rig: result.rig,
497
- profile,
498
- });
499
- for (const line of reportLines(report)) console.log(line);
500
- console.log(
501
- ` .. ${Object.entries(report.stats)
502
- .map(([k, v]) => `${k}=${v}`)
503
- .join(' ')}`,
504
- );
505
- return report.failures.length;
506
- }
507
-
508
- /**
509
- * One line per mesh kind on this cut, printed above the table.
510
- *
511
- * A legend rather than a heading: the heading used to describe the ring tier
512
- * unconditionally, so a build whose only mesh was a ribbon or a contour got a
513
- * sentence about a rim ring and a seam it does not have.
514
- */
515
- /**
516
- * What a depth map and a soft region put on a mesh, when it named either.
517
- *
518
- * The digests are the reason this prints at all: a claim about a rig can name
519
- * WHICH sheet produced it, and two runs a reader believes differ can be shown to
520
- * have read the same pixels. The ranges and counts are what say the input
521
- * reached the geometry rather than merely being resolved — a `carried 0` never
522
- * gets here (it is refused) and a `ramped 0` is a hard-edged mask, which is
523
- * legal and usually not what somebody meant.
524
- */
525
- /**
526
- * One axis's two ceilings, as `+31.41 / -18.03`, or what is unbounded on it.
527
- *
528
- * ⚠️ `none` and a number are different claims and are printed differently. A
529
- * sheet with no gradient along an axis cannot fold anything on it AT ANY ANGLE,
530
- * which is a fact about the sheet worth reading; printing `90` for it would be
531
- * a limit nothing measured.
532
- */
533
- function ceilingPair(axis: { positive: FoldLimit | null; negative: FoldLimit | null }): string {
534
- const one = (l: FoldLimit | null, sign: string) => (l === null ? `${sign}none` : `${sign}${l.degrees.toFixed(2)}°`);
535
- return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
536
- }
537
-
538
- /**
539
- * The same axis's two 1st percentiles, each with its ratio to the ceiling above
540
- * it and the population it came out of — `+64.80° x1.003 of 5988`.
541
- *
542
- * ⭐ The ratio is the whole point and it is printed rather than judged. A
543
- * ceiling set by the FORM is the floor of a band: the steepest region of a
544
- * smooth sheet has area, so the 1st percentile sits a fraction of a percent
545
- * above the minimum. A ceiling set by one bad texel has 99 % of the mesh
546
- * surviving to the form's angle while the reported number collapses — 64.58°
547
- * against 6.08° for one texel of 160,000, with the percentile unmoved at 64.80°
548
- * in both ([#412](https://github.com/firejune/rigc/issues/412),
549
- * `bench/studies/2026-09-05-noise` §6).
550
- *
551
- * Three spellings, three different claims, for the reason `ceilingPair` prints
552
- * `none` rather than 90: `+none` is a side nothing folds on at all, `+unranked
553
- * of 36` is a side whose population is too small for a first percentile to be
554
- * anything but the minimum itself, and a number is a measurement.
555
- *
556
- * ⛔ No threshold lives here. What ratio means what is in `docs/AUTHORING.md`
557
- * §3.4, because a number rigc printed an adjective beside would be a policy the
558
- * compiler invented out of a measurement — and `A39` would go on refusing at the
559
- * raw angle either way.
560
- */
561
- function spreadPair(axis: { positive: FoldLimit | null; negative: FoldLimit | null }): string {
562
- const one = (l: FoldLimit | null, sign: string) =>
563
- l === null
564
- ? `${sign}none`
565
- : l.p1 === null
566
- ? `${sign}unranked of ${l.count}`
567
- : `${sign}${l.p1.toFixed(2)}° x${(l.p1 / l.degrees).toFixed(3)} of ${l.count}`;
568
- return `${one(axis.positive, '+')} / ${one(axis.negative, '-')}`;
569
- }
570
-
571
- /** The tightest of the four, so the line that names a triangle names the right one. */
572
- function tightestFold(c: TurnCeiling): { kind: string; sign: string; limit: FoldLimit } | null {
573
- const all = [
574
- { kind: 'yaw', sign: '+', limit: c.yaw.positive },
575
- { kind: 'yaw', sign: '-', limit: c.yaw.negative },
576
- { kind: 'pitch', sign: '+', limit: c.pitch.positive },
577
- { kind: 'pitch', sign: '-', limit: c.pitch.negative },
578
- ].filter((e): e is { kind: string; sign: string; limit: FoldLimit } => e.limit !== null);
579
- if (all.length === 0) return null;
580
- return all.reduce((best, e) => (e.limit.degrees < best.limit.degrees ? e : best));
581
- }
582
-
583
- function meshDepthNote(m: CompileResult['meshes'][number]): string {
584
- const parts: string[] = [];
585
- if (m.depth) {
586
- parts.push(
587
- `depth "${m.depth.image}" ${m.depth.digest} near=${m.depth.near} zScale=${m.depth.zScale} ` +
588
- `z=[${m.depth.range[0]}, ${m.depth.range[1]}]`,
589
- );
590
- // Directly under the sheet's own line, because it is the other half of
591
- // "what did this mesh read": `z=[…]` says how much of the map's range
592
- // reached the vertices, and this says how many of them read a texel that
593
- // draws nothing (issue #449). Reported only when there is something to
594
- // report, so a mesh whose every vertex sits on drawn art gains no line.
595
- //
596
- // ⭐ A `contour` gets the OTHER half of the sentence, because rigc built
597
- // that outline and knows what it is: `buildContourMesh` returns
598
- // `hullVertices: points.length` over `offsetPolygon(simplified, margin)`,
599
- // so every vertex of a contour mesh is the traced silhouette pushed out by
600
- // the margin — there are no interior vertices for the count to be about.
601
- // Nothing is derived, inferred or thresholded to say so; it is what the
602
- // generator returns, and `generatedHullAndEdges` already cross-checks that
603
- // hull against the triangulation's own outline. Without it the line reads
604
- // as a fault on every correct contour rig, which is a diagnostic authors
605
- // learn to ignore.
606
- // Withheld, and said where the count would have stood (issue #750): the
607
- // part's texels could not be located on its page, so there is no count
608
- // that is about this part.
609
- if (m.depth.unlocated !== undefined) {
610
- parts.push(`the count of vertices on undrawn texels is not measured: ${m.depth.unlocated}. ${PAGE_GRID_UNLOCATED}`);
611
- } else if (m.depth.undrawn !== null && m.depth.undrawn > 0) {
612
- parts.push(
613
- `${m.depth.undrawn} of ${m.vertices} vertices sample a texel the part image does not draw — ` +
614
- (m.kind === 'contour'
615
- ? 'a contour\'s vertices are all traced outline, pushed out by the margin, so this is the topology and not the sheet'
616
- : 'their z is the sheet\'s reading of somewhere the part is not'),
617
- );
618
- }
619
- const c = m.depth.ceiling;
620
- parts.push(`turn ceiling yaw ${ceilingPair(c.yaw)} pitch ${ceilingPair(c.pitch)}`);
621
- const worst = tightestFold(c);
622
- if (worst !== null) {
623
- parts.push(` 1st pct yaw ${spreadPair(c.yaw)} pitch ${spreadPair(c.pitch)}`);
624
- }
625
- parts.push(
626
- worst === null
627
- ? ` nothing in this sheet folds: ${c.measured} triangle(s) measured, none with a depth gradient across it`
628
- : ` first to fold: ${worst.kind} ${worst.sign} at ${worst.limit.degrees.toFixed(2)}°, ` +
629
- `triangle ${worst.limit.triangle} [${worst.limit.ids.join(',')}], the sheet steps ` +
630
- `${depthStepLevels(worst.limit.depthStep, m.depth.zScale).toFixed(2)} level(s) across it, ` +
631
- // The same step over the range the mesh sampled (issue #448). A
632
- // suffix and not a line of its own: it is an apposition on the step
633
- // beside it, and the reading that matters is the two together — a
634
- // discontinuity says "plenty of levels" and "nearly all of them" at
635
- // once, and they have to be read in one breath to say the opposite.
636
- `which is ${worst.limit.stepShare.toFixed(3)} of the range this mesh sampled` +
637
- `${c.degenerate ? `; ${c.degenerate} triangle(s) too flat in setup to measure` : ''}`,
638
- );
639
- }
640
- if (m.soft) {
641
- parts.push(
642
- `soft "${m.soft.mask}" ${m.soft.digest} -> ${m.soft.bone}, ${m.soft.carried} carried / ${m.soft.ramped} in the falloff`,
643
- );
644
- }
645
- return parts.length === 0 ? '' : `\n ${parts.join('\n ')}`;
646
- }
647
-
648
- const MESH_KIND_NOTES: Record<CompileResult['meshes'][number]['kind'], string> = {
649
- ring: 'ring rim ring pinned on the window edge, seam ring pinned on the mask contour, aperture moves',
650
- ribbon: 'ribbon entry row pinned, rows share their weights so the strip lengthens without widening',
651
- contour: 'contour the art\'s own silhouette, every vertex pinned to the slot bone (geometry, not a deformation)',
652
- grid: 'grid a lattice over the part window at stated column and row positions, every vertex pinned to the slot bone',
653
- segments: 'segments a lattice over the part\'s alpha, every vertex weighted by distance to the bone segments it names',
654
- authored: 'authored geometry rigc did not build; it assumes nothing about the topology',
655
- };
656
-
657
- /**
658
- * The line under a `segments` mesh: how its bones share the vertices.
659
- *
660
- * ⭐ The weights are this generator's whole output, and the per-vertex numbers
661
- * are nowhere a reader can see them — so the three figures that say whether the
662
- * falloff did what was meant are printed where the mesh is: the most bones any
663
- * vertex binds (against `maxBones`), the mean, and how many vertices a single
664
- * bone owns outright. A named bone no vertex binds is said by name, because it
665
- * is in no weight and so in no other figure either.
666
- */
667
- function meshInfluenceNote(m: CompileResult['meshes'][number]): string {
668
- const inf = m.influence;
669
- if (inf === undefined) return '';
670
- const unbound = m.bones.filter((b) => !inf.bound.includes(b));
671
- const single = m.vertices === 0 ? 0 : (inf.singleBone / m.vertices) * 100;
672
- const joined = inf.keptCells - inf.artCells;
673
- return (
674
- `\n influence max ${inf.maxBones} bone(s) per vertex, mean ${inf.meanBones.toFixed(2)}, ` +
675
- `${inf.singleBone} of ${m.vertices} vertices (${single.toFixed(2)}%) on a single bone` +
676
- (unbound.length ? `; named and bound by no vertex: ${unbound.join(', ')}` : '') +
677
- `\n lattice cell ${inf.cell}px, ${inf.cols}x${inf.rows} cells, ${inf.artCells} with art, ${inf.keptCells} kept` +
678
- (inf.islands > 1 || joined > 0
679
- ? ` (${inf.islands} island(s) joined into one outline, ${joined} cell(s) added that hold no art)`
680
- : '')
681
- );
682
- }
683
-
684
- /**
685
- * Why a figure taken off a part's texels is withheld on a page whose file is
686
- * not the size its atlas declares — the tail of every line that withholds one
687
- * (issue #750). The page and its ratios come first, from `pageGridSaid`; this
688
- * says what that does to the reading and where the repair is named.
689
- */
690
- const PAGE_GRID_UNLOCATED =
691
- 'rigc lifts a part off its page at the coordinates the atlas states, and on this file those are not where the ' +
692
- "part's texels are, so a figure taken there would describe another part of the page. " +
693
- '`A06_ATLAS_PAGE_SIZE_MATCHES_PNG` refuses the page by the same ratio and names the `scale:` header that states it';
694
-
695
- /**
696
- * What a mesh measured about its own fit against the art it names, or nothing
697
- * for a mesh with no art to measure against.
698
- *
699
- * Printed for authored geometry as well as for a `contour` (issue #277): the
700
- * figure is a measurement between the emitted triangles and the PNG, so it means
701
- * the same thing whoever drew the vertices, and the silence was the defect —
702
- * an octagon rim placed on a round part's silhouette clips its own ink outline
703
- * at 94.31% and used to print nothing at all.
704
- *
705
- * The hole is appended only when there is one, so the common line is unchanged.
706
- * It is the one figure in the report that a hole moves: `coverage` and
707
- * `overshoot` are both measured against the FILLED silhouette, so spanning an
708
- * interior hole is neither missing coverage nor reaching past anything, and an
709
- * unintentional hole — a gap in the art, a stroke that failed to join — bought
710
- * fill over transparent pixels with nothing anywhere saying so (issue #275).
711
- */
712
- function meshFit(m: CompileResult['meshes'][number]): string {
713
- // The fit is a measurement against the part's texels, and on a page whose
714
- // file is not its declared size the region lift does not have them (issue
715
- // #750). It printed 68.49% / 76.24px there for a mesh that measures 100.00% /
716
- // 16.00px on the page it was packed from — two plausible numbers about
717
- // another part of the picture. So the line says what was not measured and
718
- // why, in the place the figures stood, and prints no figure.
719
- if (m.fitWithheld !== undefined) return ` fit not measured: ${m.fitWithheld}. ${PAGE_GRID_UNLOCATED}`;
720
- if (m.coverage === undefined) return '';
721
- // A count of the plate's own cells, so on a `scale:` page it is texels and
722
- // says so rather than borrowing the overshoot's unit beside it (issue #762).
723
- const hole = m.holePixels ? `, enclosing ${m.holePixels}${m.pageScale === undefined ? 'px' : ' texel(s)'} of hole` : '';
724
- return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it${meshFitGrid(m)}${hole}`;
725
- }
726
-
727
- /**
728
- * The grid a fit was taken on, when it is not the drawing's own (issue #762).
729
- *
730
- * The overshoot is stated in the drawing's pixels on every route — the unit an
731
- * attachment's size is in — and on a page that declares a `scale:` other than
732
- * 1 it was measured on the page's texels and divided by that scale. So it
733
- * carries the coarser grid's step: on `scale: 0.5` a figure moves in steps of
734
- * 2.00px of the drawing, and it need not equal the figure the page it was
735
- * packed from reads except where the distance falls on whole texels. Said
736
- * beside the figure, and nothing at all on a loose part or a page at scale 1,
737
- * where the line is the one it always was.
738
- */
739
- function meshFitGrid(m: CompileResult['meshes'][number]): string {
740
- if (m.pageScale === undefined) return '';
741
- return (
742
- ` (the drawing's pixels, measured on the page's texels at scale: ${m.pageScale} — a texel is ` +
743
- `${(1 / m.pageScale).toFixed(2)}px of the drawing)`
744
- );
745
- }
746
-
747
- /**
748
- * The triangle budget a `MESH` line is read against: the rig's, or nothing.
749
- *
750
- * 📐 It used to be the literal `80`, which was nobody's budget — the rig quoted
751
- * in issue #275 declared 64, `A13_MESH_BUDGET` measured against that 64
752
- * correctly, and the line an author actually reads printed 80. Under the default
753
- * `--profile spine` `A13` is `PROF`, so the printed number is the only budget
754
- * figure in the output and it has to be the declared one. A rig that declares
755
- * none says so in the same words `A13` SKIPs in, rather than being given a wall.
756
- */
757
- function meshBudget(rig: CompileResult['rig']): string {
758
- return rig.meshTriangleBudget === null ? '(no budget declared)' : `(budget ${rig.meshTriangleBudget})`;
759
- }
760
-
761
- /**
762
- * The `MEMBER` report block — a group track's per-member values, side by side
763
- * (issue #295).
764
- *
765
- * ## Why side by side is the whole point
766
- *
767
- * The complaint that filed #295 was not the line count. `gallery/portrait`'s
768
- * held yaw put six sibling bones' `translatex` in six separate tracks, and the
769
- * reason that is bad is that **nobody can see a wrong sign in a column that is
770
- * eighty lines from its neighbours.** FACE §3 makes the same argument from the
771
- * other side: a residual is 1–6 units where a total is 30–40, and the split is
772
- * *an auditing decision before it is a rigging one*. So the report's job is to
773
- * put the numbers in the arrangement the audit needs — one row per member, one
774
- * block per key — which is exactly the arrangement the emitted format cannot
775
- * have, because Spine keys one bone per timeline.
776
- *
777
- * ## It quotes; it does not re-derive
778
- *
779
- * The same rule as the `DEFORM` block. Every value here is the one the compiler
780
- * **emitted**, carried on `result.trackDerivations`, so the block and the
781
- * artifact cannot disagree. `derived` and `formula` are the model's own strings
782
- * from `src/trackgen.ts`, so the block names the closed form the spec stated
783
- * rather than a second reading of it.
784
- *
785
- * ## What it deliberately does not print
786
- *
787
- * **Tracks whose members all share one value** — the ordinary `groups` entry.
788
- * There is one number there and the timelines above already show it on every
789
- * member; a table of six identical rows would be a tautology, and the block
790
- * exists to make a *difference* visible. `look_l`/`look_r` in the worked example
791
- * are exactly that case and they are right to be absent from here.
792
- *
793
- * **`stagger`.** A per-member time offset is printed as it always was — on each
794
- * member's own timeline, where the shifted key times are. Repeating it here
795
- * would put one lag in two places.
796
- */
797
- function memberReportLines(result: CompileResult): string[] {
798
- if (result.trackDerivations.length === 0) return [];
799
- const out: string[] = [
800
- '',
801
- 'group members (the per-member values of one track, side by side — issue #295)',
802
- ' .. a row per member and a block per key, because a wrong sign is visible in a column of six and',
803
- ' .. invisible in six tracks. Values are the EMITTED ones, so this and the artifact cannot disagree',
804
- ' .. a group whose members all share one value is not here: there is one number and the timelines',
805
- ' .. above already carry it. `stagger` is not here either — the shifted key times are on those timelines',
806
- ];
807
- for (const entry of result.trackDerivations) {
808
- const states =
809
- entry.model === null
810
- ? 'stated per member'
811
- : `derive ${entry.model.kind} ${entry.model.stated} -> ${entry.model.projection === 'shift' ? 'the displacement' : 'the narrowing'}`;
812
- out.push(
813
- ` MEMBER ${entry.animation} ${entry.targetKind} "${entry.target}".${entry.property} ` +
814
- `t=${entry.time.toFixed(6)} ${entry.members.length} member(s) ${states}`,
815
- );
816
- if (entry.model !== null) {
817
- out.push(` ${entry.model.formula}`);
818
- for (const line of entry.model.derived) out.push(` ${line}`);
819
- }
820
- const width = Math.max(6, ...entry.members.map((m) => m.member.length));
821
- for (let i = 0; i < entry.members.length; i++) {
822
- const m = entry.members[i];
823
- const value = Array.isArray(m.value) ? m.value.join(', ') : JSON.stringify(m.value);
824
- // The model's own row carries the two inputs that produced the value — the
825
- // coordinate it read off the rig and the depth the spec stated — because
826
- // "5.513" alone is a number a reader can only take on trust, and `−62` and
827
- // `150` beside it are a claim they can check.
828
- const from = entry.model === null ? '' : ` <- ${entry.model.members[i].at >= 0 ? ' ' : ''}${entry.model.members[i].at} at depth ${entry.model.members[i].depth}`;
829
- out.push(` ${m.member.padEnd(width)} ${value.padStart(12)}${from}`);
830
- }
831
- }
832
- return out;
833
- }
834
-
835
- /**
836
- * The `DEFORM` report block — what each deform key does to the geometry, per key
837
- * and then per animation (issue #316).
838
- *
839
- * ## Why this is a report and not an assertion
840
- *
841
- * Because a 3× stretch is a real thing to author, for the same reason issue #277
842
- * settled mesh coverage as a printed figure on authored geometry rather than a
843
- * bar. The one deformed-geometry fault that has no legitimate counter-example is
844
- * the fold, and that one already IS an assertion —
845
- * `A39_DEFORM_KEEPS_TRIANGLE_WINDING`. What this block adds is **the approach to
846
- * that wall**: FACE §4.2's table of ratios down to the fold at 31.37° was
847
- * measured by rendering seven variants of `gallery/portrait` and looking at them,
848
- * and `0.637` was a number an author derived from the closed form rather than one
849
- * the tool printed.
850
- *
851
- * ## It quotes; it does not re-derive
852
- *
853
- * - the reversal and collapse counts are the **survey's**, which is A39's own
854
- * survey ([`src/deformmeasure.ts`](src/deformmeasure.ts)) — one measurement,
855
- * two readers, so the block and the gate cannot disagree about a fold;
856
- * - 🔒 and so is **the frame each key was posed in** (issue #407), which every
857
- * `DEFORM` line now names: `on a track`, or the slider that applies the
858
- * animation and the dial value its own mapping had to be inverted to. The
859
- * derivation moved and the report had to move with it — a block that went on
860
- * printing the same figures under a changed meaning would be worse than the
861
- * red it replaced;
862
- * - a key's model is the **compiler's** `transform` report (§4.11.1), so the
863
- * block names the same `kind` and parameters the spec stated;
864
- * - the fold ANGLE is nowhere here. It is A39's, derived at run time from the
865
- * grid, and a second copy of it printed beside a ratio would be a number that
866
- * goes stale when somebody moves a column;
867
- * - and a key the gate read **no winding** off — because the slot draws no pixels
868
- * of the mesh at that key's own time (issue #401) — says so on a `skipped` line
869
- * with the survey's own sentence, and is kept out of the rollup's counts,
870
- * because that line ends by claiming A39 reads the same two;
871
- * - the **spans** between the keys are the survey's too (issue #403). A `BETWEEN`
872
- * line appears wherever the closed form found a fold at a time no key lands
873
- * on, whether the gate refuses it or passes it over because nothing is drawn
874
- * there — and a `spans` line says how many were scanned even when nothing was
875
- * found, because a scan that ran and found nothing has to be distinguishable
876
- * from a scan that never ran.
877
- *
878
- * ## And what it deliberately does not print
879
- *
880
- * **Deformed coverage**, which #296 asked for. The coverage figure is rasterised
881
- * from the attachment's **uvs** against the part's alpha, and a deform moves
882
- * positions and never uvs — so it is identical at every key by construction, and
883
- * a `coverage 100.00% (setup 100.00%)` line would be a tautology wearing a
884
- * measurement's clothes. The header line says so and points at `meshes`, because
885
- * an author who came here asking whether their deform broke the coverage
886
- * deserves the answer rather than a silence. What does move is the stretch.
887
- */
888
- function deformReportLines(result: CompileResult, exempt: ReadonlySet<string>, poser: PoserName | undefined, label: string): string[] {
889
- // Read and posed through rigc's own core over the model document the build carries (issues #969, #1019) —
890
- // spine-core is not touched — and through spine-core under `--poser spine` or when the core refuses the
891
- // document, which the block then names; `tools/survey_hashes.ts` holds the two to one block. The block
892
- // itself is `deformReportBlock` (`src/deformreport.ts`), so a control renders it off either reader's survey.
893
- const input = { skeletonText: result.skeletonText, atlasText: result.atlasText, modelText: modelDocument(result.model, result.skeletonText, result.atlasText), label: `the deform survey of ${label}` };
894
- let survey: DeformSurvey;
895
- try {
896
- survey = surveyOfBuild(input, new Set(), poser === undefined ? 'auto' : poser === 'core' ? 'model' : 'spine-core');
897
- } catch (err) {
898
- // `--poser core` on a build the core refuses: a refusal of the invocation, as `render` and `check` give it.
899
- if (poser === 'core' && err instanceof CoreInputError) throw new ExplainError(`--poser core: the core refused to pose the deform survey's model document — ${err.message}`);
900
- throw err;
901
- }
902
- return deformReportBlock(survey, result.deformTransforms, exempt);
903
- }
904
-
905
- /**
906
- * The header the `scale` rows carry, because the figure beside them lies without
907
- * it.
908
- *
909
- * ⛔ Three things it has to say, and each one is a way the number is wrong if
910
- * taken at face value:
911
- * - it is the key's OWN factor. A nonuniform parent shears its children, so
912
- * the drawn area is not this product;
913
- * - a key that moved only one axis has no product to state, and gets none
914
- * rather than an invented 1 on the other;
915
- * - a uniform scale has a product too, and it is a zoom rather than a squash.
916
- */
917
- const SCALE_PRODUCT_NOTE =
918
- '.. x·y is the key\'s own local area factor: ~1.00 is the volume kept, and it is a READING, never a rule — ' +
919
- 'a nonuniform parent shears this, and a uniform scale has a product without being a squash';
920
-
921
- /**
922
- * `x·y` for a `scale` key that states both, and nothing otherwise.
923
- *
924
- * ⭐ Why it is here at all: `explain` ALREADY prints this reading for the other
925
- * spelling of squash and stretch. A `transform: affine` deform key reports
926
- * `area x1.020800`, which is exactly its own `0.88 × 1.16` — so the author who
927
- * reaches for the advanced spelling is told whether the volume held and the
928
- * author who reaches for the cheap one is not, while `docs/MOTION.md` §7 points
929
- * a first candidate at the cheap one on purpose. That asymmetry is the defect;
930
- * this is not a new kind of number (issue #377).
931
- *
932
- * 🔒 A reading and never an assertion. `deformReportLines` states the test a
933
- * geometric figure has to pass to become a gate — no legitimate counter-example
934
- * — and this fails it in quantity: a shadow, a zoom, a cartoon squash that
935
- * gains mass on purpose. Volume preservation is a style commitment no spec can
936
- * declare, so a bar here would be one consumer's house style failing correct
937
- * foreign data. There is no honest SKIP either: an absent declaration is not
938
- * "nothing to measure", it is "no way to know what was meant".
939
- */
940
- /**
941
- * An emitted key as the 4.3 parser reads it: every field its kind's
942
- * `PARSER_DEFAULTS` row lists and the key leaves out, at the row's value, then
943
- * the key's own fields. The emitter leaves a field at its parser default out
944
- * (issue #716), so a report that reads the file needs the row to say what the
945
- * runtime reads there — and reads it from the one table the emitter used.
946
- */
947
- function asParsed(kind: string, key: Record<string, unknown>): Record<string, unknown> {
948
- const row = PARSER_DEFAULTS[kind];
949
- if (row === undefined) return key;
950
- const site = { object: key, previous: () => null };
951
- const filled: Record<string, unknown> = {};
952
- for (const field of Object.keys(row)) {
953
- const value = parserReading(row, site, field);
954
- if (value !== undefined) filled[field] = value;
955
- }
956
- return { ...filled, ...key };
957
- }
958
-
959
- /** An emitted key's time as the parser reads it — `0` where the key leaves it out. */
960
- function keyTimeOf(kind: string, key: Record<string, unknown>): unknown {
961
- return asParsed(kind, key).time;
962
- }
963
-
964
- function scaleProduct(timelineName: string, key: Record<string, unknown>): string {
965
- if (timelineName !== 'scale') return '';
966
- const x = key.x;
967
- const y = key.y;
968
- // Both axes, or nothing: a key that moved one axis has no area factor, and
969
- // defaulting the other to 1 would invent the very number being reported.
970
- if (typeof x !== 'number' || typeof y !== 'number') return '';
971
- return ` x·y=${(x * y).toFixed(4)}`;
972
- }
973
-
974
- /**
975
- * Read one non-negative integer flag, or its default.
976
- *
977
- * A usage error rather than a `NaN` that reaches the packer: `--padding two`
978
- * would otherwise place every region at NaN and write a blank page, which is a
979
- * green build and an empty picture.
980
- */
981
- function readIntFlag(flags: Record<string, string>, name: string, fallback: number): number {
982
- const raw = flags[name];
983
- if (raw === undefined) return fallback;
984
- if (!/^\d+$/.test(raw)) throw new UsageError(`--${name} takes a non-negative integer, got ${JSON.stringify(raw)}`);
985
- return Number(raw);
986
- }
987
-
988
- /**
989
- * Read `--page-edges`, or its default — `pot`.
990
- *
991
- * An unknown value is a usage error for `readProfile`'s reason: a typo that fell
992
- * back to `pot` would hand the caller who asked for the smaller page the bigger
993
- * one, green, and say nothing.
994
- */
995
- function readPageEdges(flags: Record<string, string>): PageEdges {
996
- const raw = flags['page-edges'];
997
- if (raw === undefined) return DEFAULT_PAGE_EDGES;
998
- const found = PAGE_EDGES.find((e) => e === raw);
999
- if (!found) throw new UsageError(`--page-edges ${JSON.stringify(raw)}; known values: ${PAGE_EDGES.join(', ')}`);
1000
- return found;
1001
- }
1002
-
1003
- /**
1004
- * One `DROP` line, written once because two outcomes print it.
1005
- *
1006
- * A build that succeeds prints it in its report; a build that REFUSES prints it
1007
- * under the refusal (issue #671), and the two have to be the same line or the
1008
- * failing run would be quoting a different fact from the one the green run
1009
- * shows. What it names — a file or a region — is `droppedStateReason`'s, in the
1010
- * compiler, beside the code that decided which of the two was consulted.
1011
- */
1012
- function dropLine(dropped: DroppedState): string {
1013
- return ` DROP ${dropped.slot}/${dropped.state}: ${droppedStateReason(dropped)} (state not emitted)`;
1014
- }
1015
-
1016
- /**
1017
- * The rectangle a region occupies **on its page**, for a line that has already
1018
- * said where the region is — and the empty string where the page rectangle is
1019
- * the one `bounds:` already states.
1020
- *
1021
- * ## The fact no surface an author reads carried (issue #718)
1022
- *
1023
- * The atlas line beside this clause prints the DRAWING's size, because that is
1024
- * what an attachment's width and height mean. A packer that turned the drawing a
1025
- * quarter to fit it wrote `bounds:` in the drawing's orientation too. So an
1026
- * author holding the pack and the build report had neither end of the rectangle
1027
- * they have to cut out of the page to measure a part against a rendered frame —
1028
- * and the one place rigc printed it was `A06`'s overlap text, reachable only
1029
- * under `--profile spine-html`. The knowledge was in the tree the whole time:
1030
- * `pageFootprint` has derived this rectangle for every reader of it since issue
1031
- * #579, and nothing an author reads said it.
1032
- *
1033
- * ⚠️ **The condition is `pageFootprint`'s own answer, not a second reading of
1034
- * `degrees`.** Re-spelling that predicate here is the exact duplication #579 was
1035
- * filed on — four readers derived this rectangle and two derived it wrongly — so
1036
- * the clause asks the function whether its answer differs from the `bounds:`
1037
- * line, and prints only then.
1038
- *
1039
- * 🔸 A consequence worth stating rather than leaving to be discovered: a region
1040
- * whose KEPT rectangle is square is silent here, because a quarter turn leaves
1041
- * its footprint the same two numbers and there is nothing the pack does not
1042
- * already say. The general rule — `bounds` is the unturned size, the footprint
1043
- * is its transpose at `rotate: 90` and `rotate: 270`, and which way to turn the
1044
- * rectangle to recover the drawing — belongs to an author's own reading and is
1045
- * stated in `docs/AUTHORING.md` §0.2, which holds for every region including
1046
- * that one.
1047
- */
1048
- function pageRectangle(region: AtlasRegion): string {
1049
- const foot = pageFootprint(region);
1050
- return foot.width === region.width && foot.height === region.height
1051
- ? ''
1052
- : `, occupies ${foot.width}x${foot.height}`;
1053
- }
1054
-
1055
- function cmdBuild(flags: Record<string, string>): void {
1056
- const { label, opts } = resolveCut(flags);
1057
- const profile = readProfile(flags);
1058
- const packing = flags.pack !== undefined;
1059
- // Two combinations are refused rather than silently resolved, because in each
1060
- // one the two flags disagree about a single question and there is no answer
1061
- // that is not a guess about which the caller meant. (There were three until
1062
- // issue #266 — see the note below the second.)
1063
- if (packing && opts.atlasInPath !== undefined) {
1064
- throw new UsageError(
1065
- '--pack and --atlas-in are opposite directions through the same door: --pack MAKES an atlas out of the ' +
1066
- 'loose parts, --atlas-in resolves the parts against one somebody already made. Pick one',
1067
- );
1068
- }
1069
- if (packing && flags['copy-images'] !== undefined) {
1070
- throw new UsageError(
1071
- '--pack already writes self-contained pages into --out (that is what packing is), and --copy-images copies ' +
1072
- 'the loose part PNGs, which a packed atlas does not reference. Drop --copy-images',
1073
- );
1074
- }
1075
- // The copy itself happens after the gate (below). The header has to know NOW,
1076
- // because the skeleton text the gate reads is the skeleton text that is written
1077
- // — `skeleton.images` says where the parts will be (issue #370).
1078
- if (flags['copy-images'] !== undefined) opts.copyImages = true;
1079
- // `--pack --profile spine-html` used to be the third refusal here, because
1080
- // A06's coverage clause was "one part per page" flat and a legitimate pack
1081
- // arrived at the gate reading as a defect. Since issue #266's second follow-up
1082
- // that clause is "one part per page OR a tiling page", so the combination is
1083
- // now a build like any other — and it is the only one that puts the renderer's
1084
- // own rulebook over shared-page sampling.
1085
- if (!packing) {
1086
- for (const name of ['page-size', 'padding', 'page-edges'] as const) {
1087
- if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
1088
- }
1089
- }
1090
- console.log(`rigc build ${label}`);
1091
- // Named explicitly and on their own lines rather than folded into the header
1092
- // above: with two input files, a header that names only one of them (the rig,
1093
- // historically) reads as though it were the one at fault whenever the error
1094
- // that follows actually comes from the other.
1095
- console.log(` .. rig ${opts.rigPath}`);
1096
- console.log(` .. motion ${opts.motionPath}`);
1097
- const result = compile(opts);
1098
-
1099
- if (opts.atlasInPath !== undefined) console.log(` .. atlas-in ${opts.atlasInPath}`);
1100
- console.log(` .. ${result.images.length} part page(s):`);
1101
- for (const img of result.images) {
1102
- // An imported part says where on the page it came from, because "resolved
1103
- // against a region" is the claim `--atlas-in` makes and a line that only
1104
- // repeated the page filename would look identical for all of them. A page
1105
- // that declares a `scale:` also says so and shows the texels it was read
1106
- // from: the size on the left is the DRAWING's and the rectangle is the
1107
- // pack's, and issue #267 is the report that printed the second as the first.
1108
- //
1109
- // `pageRectangle` closes the line's last silence (issue #718), and it is
1110
- // placed LAST rather than beside the turn it follows from, which is where
1111
- // the card put it. The two clauses collide nowhere else, and the collision
1112
- // is real: `scale 0.5 (373x106 texels)` is itself a size, so
1113
- // `rotate 90, occupies 106x373 scale 0.5 (…)` reads as though the footprint
1114
- // were the scaled quantity. As a trailing clause of the whole location
1115
- // phrase it is unambiguous with a `scale:` line and identical to the card's
1116
- // wording without one, which is every pack that has no `scale:` to state.
1117
- const where =
1118
- img.atlas === undefined
1119
- ? img.page
1120
- : `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
1121
- (img.atlasScale === undefined
1122
- ? ''
1123
- : ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`) +
1124
- pageRectangle(img.atlas);
1125
- console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
1126
- }
1127
- for (const d of result.droppedStates) console.log(dropLine(d));
1128
- // "The optional slots are optional" is a claim about this code path, so this
1129
- // code path says which ones it left out rather than being silently right.
1130
- for (const a of result.absentParts) {
1131
- console.log(` ABSENT ${a.slot}: ${a.why} — slot not emitted`);
1132
- }
1133
- for (const m of result.meshes) {
1134
- console.log(
1135
- ` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
1136
- `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}` +
1137
- meshDepthNote(m) +
1138
- meshInfluenceNote(m),
1139
- );
1140
- }
1141
- for (const ph of result.physics) {
1142
- console.log(
1143
- ` PHYS ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] ` +
1144
- `mix=${ph.mix}${ph.drivesMesh ? ' <- drives a mesh: its canvas re-rasterises while the spring settles' : ''}`,
1145
- );
1146
- }
1147
-
1148
- // The model's document is spelled before the gate, so the text A18 compares
1149
- // is the text written, and a model the document cannot carry is refused
1150
- // before anything is (issue #922). It states where each region sits in the
1151
- // atlas written beside it (`pages`, issue #1016), so it is spelled from that
1152
- // atlas: under `--copy-images` the text with the copies' page names, planned
1153
- // here from the text alone (`plannedPageCopies`) and copied after the gate.
1154
- // Under `--pack` the pages move again after this gate, and the document
1155
- // written is the one the packed gate below spells and compares.
1156
- const copying = flags['copy-images'] !== undefined;
1157
- const writtenAtlas = (atlasText: string): string => (copying ? plannedPageCopies(atlasText, opts.outDir).atlasText : atlasText);
1158
- let modelText = modelDocument(result.model, result.skeletonText, writtenAtlas(result.atlasText));
1159
- console.log(` .. validate (spine-core round trip + machine assertions, profile ${profile})`);
1160
- const failures = runGate(result, modelText, opts, profile, writtenAtlas);
1161
- if (failures > 0) {
1162
- console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
1163
- process.exit(1);
1164
- }
1165
-
1166
- mkdirSync(opts.outDir, { recursive: true });
1167
-
1168
- // `--copy-images`: `--out` is otherwise NOT self-contained — a page's default
1169
- // path is relative to the source art (often `../parts/foo.png`), which is
1170
- // correct for a build sitting beside the project it came from and breaks the
1171
- // moment the directory is zipped, committed or moved on its own (issue #217).
1172
- // Opt-in only: the default stays exactly what it has always been.
1173
- //
1174
- // What is copied is what the ATLAS names, not what the image list holds: under
1175
- // `--atlas-in` the two are different lists, and rebuilding the text from the
1176
- // second wrote a file the pack never contained — zero bytes for a rig that
1177
- // declares no parts, one fabricated page per part for a rig that does, both of
1178
- // them green here because the gate above had already read the compile's own
1179
- // text (issue #693, `src/emit.ts`).
1180
- let atlasText = result.atlasText;
1181
- if (flags['copy-images'] !== undefined) {
1182
- const copied = copyAtlasPages(atlasText, opts.outDir);
1183
- // The document's pages were spelled from the plan; the copy is held to it before anything of the pair is written.
1184
- if (copied.atlasText !== writtenAtlas(result.atlasText)) {
1185
- throw new Error(`internal: --copy-images wrote an atlas whose page names are not the ones ${MODEL_DOCUMENT_FILE} was spelled with — nothing of the pair was written`);
1186
- }
1187
- atlasText = copied.atlasText;
1188
- console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
1189
- for (const p of copied.pages) {
1190
- const note = p.to === basename(p.from) ? '' : ' (renamed — basename collision)';
1191
- console.log(` .. ${p.to.padEnd(24)} <- ${p.from} (${p.regions} region(s))${note}`);
1192
- }
1193
- }
1194
-
1195
- // `--pack`: the parts go onto shared pages, which are written here as real
1196
- // PNGs, so `--out` is self-contained by construction. The atlas above stays
1197
- // the one the gate just read — packing changes only the ARRANGEMENT of the
1198
- // bytes, and the sizes in `result.images` are still the ones measured off the
1199
- // loose PNGs (see src/atlas.ts's header).
1200
- if (packing) {
1201
- const packOpts = {
1202
- pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
1203
- padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
1204
- pageEdges: readPageEdges(flags),
1205
- pageStem: 'skeleton',
1206
- };
1207
- const inputs = result.images.map((img) => ({
1208
- region: img.region,
1209
- absPath: img.absPath,
1210
- width: img.width,
1211
- height: img.height,
1212
- }));
1213
- const packed = packAtlas(inputs, packOpts);
1214
- atlasText = packed.atlasText;
1215
- for (const page of packed.pages) {
1216
- page.plate.writePng(join(opts.outDir, page.name));
1217
- console.log(
1218
- ` .. pack: ${page.name} ${page.width}x${page.height}, ` +
1219
- `${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
1220
- `${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}` +
1221
- (packOpts.pageEdges === 'free' ? ', page edges free' : ''),
1222
- );
1223
- }
1224
- for (const place of packed.placements) {
1225
- console.log(
1226
- ` .. ${place.region.padEnd(24)} ${place.width}x${place.height} -> ` +
1227
- `${packed.pages[place.page].name} @ ${place.x},${place.y}`,
1228
- );
1229
- }
1230
- // The pages are on disk now, so the packed pair can be gated as an artifact
1231
- // rather than trusted as a construction: A17 stats every page, A06 reads its
1232
- // IHDR back, A07 re-reads the text shape, A08 re-joins every attachment onto
1233
- // a region, and A18 compares a second independent compile+pack. Two gates on
1234
- // one build is the cost of shipping a second atlas shape.
1235
- console.log(' .. validate (packed atlas, pages on disk)');
1236
- const packAgain = packAtlas(
1237
- compile(opts).images.map((img) => ({
1238
- region: img.region,
1239
- absPath: img.absPath,
1240
- width: img.width,
1241
- height: img.height,
1242
- })),
1243
- packOpts,
1244
- );
1245
- // The document written is spelled from the packed atlas, and this gate's A18 compares it with a
1246
- // second compile's spelled from the second, independent pack (issue #1016).
1247
- modelText = modelDocument(result.model, result.skeletonText, atlasText);
1248
- const packFailures = runGate(result, modelText, opts, profile, (text) => text, { text: atlasText, again: packAgain.atlasText });
1249
- if (packFailures > 0) {
1250
- console.error(
1251
- `rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
1252
- `${opts.outDir}, the skeleton/atlas pair was not`,
1253
- );
1254
- process.exit(1);
1255
- }
1256
- }
1257
-
1258
- writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
1259
- writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
1260
- // rigc's own record of the compiled rig (`rigc-compiled/3`, issue #922;
1261
- // its `pages` the atlas just written, issue #1016), written with the pair
1262
- // and only after the same gate — under `--pack`, the packed one. rigc's own posing core
1263
- // reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
1264
- writeFileSync(join(opts.outDir, MODEL_DOCUMENT_FILE), modelText);
1265
- console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
1266
- console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
1267
- console.log(`rigc: wrote ${join(opts.outDir, MODEL_DOCUMENT_FILE)}`);
1268
- // The next command is part of the message (issue #837). A green build is the
1269
- // moment somebody wants to see what came out, and the one page rigc writes
1270
- // for that is `preview` of exactly this directory — so the line names it,
1271
- // with the path already resolved. Printed only here: a red build wrote
1272
- // nothing, so there is nothing to look at and no line.
1273
- console.log(`rigc: look at it: rigc preview --candidate ${opts.outDir}`);
1274
- }
1275
-
1276
- /**
1277
- * Where a pair of artifacts lives, given what the caller pointed at.
1278
- *
1279
- * Two shapes, because rigc's own output and a foreign export are named
1280
- * differently and both have to be gateable. rigc writes `skeleton.json` +
1281
- * `skeleton.atlas` into a directory. Everybody else writes whatever the editor
1282
- * called the project, and the official examples are not even consistent with
1283
- * themselves — `7-anticipation/export/` holds `sack-pro.json`, `spineboy/export/`
1284
- * holds two skeletons and two atlases.
1285
- *
1286
- * ⚠️ When more than one atlas sits beside the skeleton, this refuses to choose.
1287
- * Guessing by name would be wrong on the corpus that motivated it: `spineboy-ess`
1288
- * shares a longer prefix with `spineboy-run.atlas` than with the `spineboy.atlas`
1289
- * it actually uses, so the plausible heuristic picks the wrong file and every
1290
- * attachment then resolves against the wrong pixels — silently, which is the
1291
- * exact failure mode this tool exists to remove.
1292
- */
1293
- function resolveArtifacts(target: string, atlasFlag: string | undefined): { skeletonPath: string; atlasPath: string } {
1294
- const abs = resolve(target);
1295
- if (!existsSync(abs)) throw new UsageError(`nothing at ${abs}`);
1296
- if (statSync(abs).isDirectory()) {
1297
- return {
1298
- skeletonPath: join(abs, 'skeleton.json'),
1299
- atlasPath: atlasFlag ? resolve(atlasFlag) : join(abs, 'skeleton.atlas'),
1300
- };
1301
- }
1302
- if (!abs.endsWith('.json')) throw new UsageError(`${abs} is neither a directory nor a .json skeleton`);
1303
- if (atlasFlag) return { skeletonPath: abs, atlasPath: resolve(atlasFlag) };
1304
- const dir = dirname(abs);
1305
- const atlases = readdirSync(dir)
1306
- .filter((f) => f.endsWith('.atlas'))
1307
- .sort();
1308
- if (atlases.length === 1) return { skeletonPath: abs, atlasPath: join(dir, atlases[0]) };
1309
- if (atlases.length === 0) throw new UsageError(`no .atlas beside ${abs}; name one with --atlas <path>`);
1310
- throw new UsageError(
1311
- `${atlases.length} atlases beside ${abs} (${atlases.join(', ')}); name the right one with --atlas <path> ` +
1312
- '— guessing by filename is how an attachment quietly resolves against the wrong page',
1313
- );
1314
- }
1315
-
1316
- function cmdValidate(flags: Record<string, string>, positional: string[]): void {
1317
- // A bare directory validates what is on disk. Naming the cut as well lets the
1318
- // gate re-derive the declared durations and the structural expectations, which
1319
- // a directory alone cannot supply — and the report says which it had.
1320
- const named = flags.cut !== undefined || flags.rig !== undefined;
1321
- const profile = readProfile(flags);
1322
- const derivedOpts = named ? resolveCut(flags).opts : null;
1323
- const { skeletonPath, atlasPath } = resolveArtifacts(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas);
1324
- console.log(`rigc validate ${skeletonPath}`);
1325
- console.log(` .. atlas ${atlasPath}`);
1326
- const skeletonText = readFileSync(skeletonPath, 'utf8');
1327
- const atlasText = readFileSync(atlasPath, 'utf8');
1328
- const derived = derivedOpts ? compile(derivedOpts) : null;
1329
-
1330
- const report = validate({
1331
- skeletonText,
1332
- atlasText,
1333
- atlasDir: dirname(atlasPath),
1334
- declaredDurations: derived?.declaredDurations,
1335
- rig: derived?.rig,
1336
- profile,
1337
- });
1338
- for (const line of reportLines(report)) console.log(line);
1339
- if (report.failures.length > 0) {
1340
- console.error(`rigc: ${report.failures.length} assertion(s) failed`);
1341
- process.exit(1);
1342
- }
1343
- console.log('rigc: green');
1344
- }
1345
-
1346
- /**
1347
- * `--as <candidate>=<reference>`, one pair per occurrence.
1348
- *
1349
- * ⚠️ Spelled with a pair where `check --as <name>` takes one name, and the
1350
- * difference is in what the two commands have on the other side. `check`
1351
- * measures against a rendered frame SET, which already carries the reference
1352
- * animation's name in its own directory, so one name closes the gap. `diff` has
1353
- * two skeletons and either may have its own vocabulary, so one name says which
1354
- * shot on which side and leaves the other unanswered. The direction — candidate
1355
- * first — is the one `bonedist`'s correspondence file already writes its
1356
- * `animations` map in.
1357
- *
1358
- * Every refusal here is a UsageError because every one of them is about the
1359
- * flag's own value, and each names what it read: a value with no `=`, an empty
1360
- * side, a name repeated on either side, and a name no animation on that side
1361
- * answers to. ⛔ The last of those is a refusal rather than a dropped pair for
1362
- * the reason a miss is refused by name everywhere else in this tool — a typo
1363
- * that quietly measured less would be a report about a pairing the caller did
1364
- * not ask for.
1365
- */
1366
- function readAnimationPairs(values: string[], candidate: unknown, reference: unknown): DiffAnimationPair[] {
1367
- const animationsOf = (root: unknown): string[] => {
1368
- const anims = (root as { animations?: unknown } | null)?.animations;
1369
- return typeof anims === 'object' && anims !== null && !Array.isArray(anims) ? Object.keys(anims) : [];
1370
- };
1371
- const have = { candidate: animationsOf(candidate), reference: animationsOf(reference) };
1372
- const pairs: DiffAnimationPair[] = [];
1373
- for (const value of values) {
1374
- const at = value.indexOf('=');
1375
- if (at < 0) {
1376
- throw new UsageError(
1377
- `--as ${JSON.stringify(value)} is not a pair. It takes <candidate>=<reference> — two animation names joined ` +
1378
- 'by `=`, because diff compares two skeletons and either may have its own name for the shot. The candidate ' +
1379
- `has [${have.candidate.join(', ') || 'none'}] and the reference has [${have.reference.join(', ') || 'none'}].`,
1380
- );
1381
- }
1382
- const pair = { candidate: value.slice(0, at), reference: value.slice(at + 1) };
1383
- if (pair.candidate === '' || pair.reference === '') {
1384
- throw new UsageError(
1385
- `--as ${JSON.stringify(value)} leaves the ${pair.candidate === '' ? 'candidate' : 'reference'} side empty; ` +
1386
- 'it takes <candidate>=<reference>, a name on each side',
1387
- );
1388
- }
1389
- for (const side of ['candidate', 'reference'] as const) {
1390
- if (!have[side].includes(pair[side])) {
1391
- throw new UsageError(
1392
- `--as ${JSON.stringify(value)} names no ${side} animation: the ${side} has ` +
1393
- `[${have[side].join(', ') || 'none'}] and not ${JSON.stringify(pair[side])}`,
1394
- );
1395
- }
1396
- if (pairs.some((p) => p[side] === pair[side])) {
1397
- throw new UsageError(
1398
- `--as pairs the ${side} animation ${JSON.stringify(pair[side])} twice; each animation may be in one pair, ` +
1399
- 'or the block would compare one shot against two',
1400
- );
1401
- }
1402
- }
1403
- pairs.push(pair);
1404
- }
1405
- return pairs;
1406
- }
1407
-
1408
- function cmdDiff(flags: Record<string, string>, lists: Record<string, string[]>, positional: string[]): void {
1409
- const [candidate, reference] = positional;
1410
- if (!candidate || !reference) throw new UsageError('diff takes two paths: <candidate.json> <reference.json>');
1411
- const candidatePath = resolve(candidate);
1412
- const referencePath = resolve(reference);
1413
- for (const path of [candidatePath, referencePath]) {
1414
- if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
1415
- }
1416
- const candidateJson = readJsonFile(candidatePath);
1417
- const referenceJson = readJsonFile(referencePath);
1418
- const animationPairs = readAnimationPairs(lists.as ?? [], candidateJson, referenceJson);
1419
- const report = diffSkeletons(candidateJson, referenceJson, { animationPairs });
1420
- console.log('rigc diff');
1421
- for (const line of diffLines(report, { candidate: candidatePath, reference: referencePath })) console.log(line);
1422
- if (flags.json !== undefined) {
1423
- // ⚠️ `...report` carries its OWN `candidate` and `reference` — the raw
1424
- // per-side counts. Spelling the paths under those two names put them on
1425
- // the losing side of the spread, so the written report named neither file
1426
- // it compared. The paths get their own keys.
1427
- writeJson(flags.json, { candidatePath, referencePath, ...report });
1428
- }
1429
- }
1430
-
1431
- /**
1432
- * check — how close does the candidate LOOK to the reference frames?
1433
- *
1434
- * ⭐ The gate cannot see a wrong animation. It parses, it steps, it refuses the
1435
- * degenerate — and a rig whose easings are all reversed passes it green, which is
1436
- * not a hypothetical: ladder rung 1's first honest run shipped exactly that build
1437
- * and the validator was structurally incapable of noticing. `diff` cannot see it
1438
- * either, because it compares structure and a reversed curve is the same curve
1439
- * count. The only thing that can is a picture, so this renders the candidate into
1440
- * the reference's own frame and compares pixels.
1441
- *
1442
- * 🔒 It never reads the reference skeleton — see `src/check.ts`. That is what
1443
- * keeps it usable **inside** an authoring loop rather than at the finish line the
1444
- * way `bench` is: an author may run it as often as they like without their run
1445
- * stopping being an authoring run.
1446
- *
1447
- * There is no pass mark, for the same reason `diff` has none.
1448
- */
1449
- function readCheckFlags(
1450
- flags: Record<string, string>,
1451
- ): Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom' | 'skin'> {
1452
- const out: Pick<CheckOptions, 'fps' | 'viewport' | 'as' | 'framing' | 'textureFrom' | 'skin'> = {};
1453
- if (flags.framing !== undefined) {
1454
- if (flags.framing !== 'per-shot' && flags.framing !== 'shared') {
1455
- throw new UsageError('--framing takes per-shot (the default) or shared');
1456
- }
1457
- out.framing = flags.framing;
1458
- }
1459
- if (flags.fps !== undefined) {
1460
- const fps = Number(flags.fps);
1461
- if (!Number.isFinite(fps) || fps <= 0) throw new UsageError('--fps must be a positive number');
1462
- out.fps = fps;
1463
- }
1464
- if (flags.viewport !== undefined) {
1465
- const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
1466
- if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
1467
- throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
1468
- }
1469
- if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
1470
- out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
1471
- }
1472
- if (flags.as !== undefined) out.as = flags.as;
1473
- // The name is not checked against the candidate here: `check` owns that
1474
- // refusal, because it is the side that has the skeleton open and can list the
1475
- // skins it declares. `render` checks its own for the same reason.
1476
- if (flags.skin !== undefined) out.skin = flags.skin;
1477
- if (flags['texture-from'] !== undefined) {
1478
- const path = resolve(flags['texture-from']);
1479
- if (!existsSync(path)) {
1480
- throw new UsageError(
1481
- `--texture-from ${path} is not a file. It takes the ATLAS the reference frames were rendered through — the ` +
1482
- "example's own .atlas — so check can measure how much of the MAE is texture resampling.",
1483
- );
1484
- }
1485
- out.textureFrom = { atlasText: readFileSync(path, 'utf8'), atlasDir: dirname(path), label: flags['texture-from'] };
1486
- }
1487
- return out;
1488
- }
1489
-
1490
- function runCheck(
1491
- candidate: string,
1492
- atlasFlag: string | undefined,
1493
- framesDir: string,
1494
- flags: Record<string, string>,
1495
- plates?: CheckPlates,
1496
- ): CheckReport {
1497
- const { skeletonPath, atlasPath, atlasText } = resolveDrawable(candidate, atlasFlag);
1498
- // The poser is `render`'s choice, over the same two files (issue #968): the
1499
- // paths are what `candidatePosers` looks beside for `skeleton.model.json`.
1500
- const poser = readPoserFlag(flags);
1501
- return checkAgainstFrames({
1502
- skeletonText: readSkeletonText(skeletonPath),
1503
- atlasText,
1504
- atlasDir: dirname(atlasPath),
1505
- framesDir,
1506
- labels: { skeleton: skeletonPath, atlas: atlasText === null ? `${atlasPath} — not there (${ATLAS_ABSENT})` : atlasPath },
1507
- candidatePaths: { skeleton: skeletonPath, atlas: atlasPath },
1508
- ...(poser === undefined ? {} : { poser }),
1509
- ...readCheckFlags(flags),
1510
- ...(plates === undefined ? {} : { plates }),
1511
- });
1512
- }
1513
-
1514
- /**
1515
- * `check --out <dir>`, refused before anything is compared when it cannot be
1516
- * written — see `src/checkpics.ts` for what goes there.
1517
- *
1518
- * Two refusals and no others. A **file** at the path is not a directory of
1519
- * pictures, and saying so beats the `ENOTDIR` a write would throw after the
1520
- * whole comparison had run. And a directory that **is `--frames` or holds it**
1521
- * is refused because `<out>/<set>/` is cleared and `<out>/frames.json` written,
1522
- * as `render` does to its own: there, that is the reference set being deleted by
1523
- * the command that reads it — `check --frames render --out render` would clear
1524
- * `render/heavy` and put a picture sidecar where the frames' own was. A fresh
1525
- * path, and an existing directory anywhere else, are written to.
1526
- */
1527
- function readCheckOut(outFlag: string, framesFlag: string): string {
1528
- const out = resolve(outFlag);
1529
- if (existsSync(out) && !statSync(out).isDirectory()) {
1530
- throw new UsageError(`check: --out ${out} is a file; it names a directory`);
1531
- }
1532
- const frames = resolve(framesFlag);
1533
- const within = relative(out, frames);
1534
- if (within === '' || (!within.startsWith('..') && !isAbsolute(within))) {
1535
- throw new UsageError(
1536
- `check: --out ${out} ${within === '' ? 'is' : 'holds'} --frames ${frames}; the pictures would be written ` +
1537
- 'over the frames they are pictures of (each set directory under --out is cleared first) — name a directory ' +
1538
- 'outside it',
1539
- );
1540
- }
1541
- return out;
1542
- }
1543
-
1544
- function cmdCheck(flags: Record<string, string>): void {
1545
- if (flags.candidate === undefined) throw new UsageError('check needs --candidate <dir | skeleton.json>');
1546
- if (flags.frames === undefined) throw new UsageError('check needs --frames <dir> — a rendered reference frame set');
1547
- const out = flags.out === undefined ? null : readCheckOut(flags.out, flags.frames);
1548
- const allFrames = flags['all-frames'] !== undefined;
1549
- // Only asked for when there is somewhere to put the pictures: without --out
1550
- // nothing is kept, and the run is the run it was before --out existed.
1551
- const plates = out === null ? undefined : new CheckPlates({ allFrames });
1552
- const report = runCheck(flags.candidate, flags.atlas, flags.frames, flags, plates);
1553
- console.log('rigc check');
1554
- for (const line of checkLines(report, { allFrames })) console.log(line);
1555
- if (flags.json !== undefined) writeJson(flags.json, report);
1556
- if (out !== null && plates !== undefined) {
1557
- for (const set of writeCheckPictures(out, report, plates, { allFrames })) {
1558
- const which = set.frames.length === 0 ? 'nothing compared' : set.every ? 'every compared frame' : 'the frames worth reading';
1559
- console.log(` .. ${set.dir.padEnd(16)} ${set.frames.length} picture(s), ${which} -> ${set.path}`);
1560
- }
1561
- console.log(`rigc: wrote ${join(out, FRAMES_SIDECAR)}`);
1562
- }
1563
- }
1564
-
1565
- function writeJson(target: string, body: unknown): void {
1566
- const out = resolve(target);
1567
- mkdirSync(dirname(out), { recursive: true });
1568
- writeFileSync(out, `${JSON.stringify(body, null, 2)}\n`);
1569
- console.log(`rigc: wrote ${out}`);
1570
- }
1571
-
1572
- // ---------------------------------------------------------------------------
1573
- // seeing the result — render and preview
1574
- // ---------------------------------------------------------------------------
1575
- //
1576
- // ⭐ Why two commands exist for one question. `validate` says the artifact is
1577
- // valid, `check` says how close it is to reference frames — and a first user has
1578
- // neither a reference nor any way to look at what they built. A rig whose head
1579
- // sits visibly off its torso passes the gate, loads in `spine-core` and steps
1580
- // cleanly, because the offsets are the ones the spec asked for. The only remedy
1581
- // is looking (issue #216).
1582
- //
1583
- // `render` looks with OUR rasteriser: PNGs on disk, no browser, no network, and
1584
- // the same frame geometry `check` compares against — so its output is a frame set
1585
- // like any other, sidecar included. `preview` looks with ESOTERIC'S, in one HTML
1586
- // file, which is the stronger statement of the two: a rig that plays there has
1587
- // been played by the reference implementation rather than by ours (issue #151).
1588
- //
1589
- // Both take a COMPILED artifact rather than a rig and motion spec. That is what
1590
- // `check`, `bench` and `validate` all take, it is what `build --out` leaves
1591
- // behind, and it keeps `--out` meaning one thing per command instead of naming
1592
- // the build directory on the way in and the pictures on the way out.
1593
-
1594
- /** Both commands' shared front door: which artifact, and what is in it. */
1595
- function resolveViewable(flags: Record<string, string>): {
1596
- skeletonPath: string;
1597
- atlasPath: string;
1598
- atlasDir: string;
1599
- } {
1600
- if (flags.candidate === undefined) {
1601
- throw new UsageError('needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
1602
- }
1603
- const { skeletonPath, atlasPath } = resolveArtifacts(flags.candidate, flags.atlas);
1604
- for (const path of [skeletonPath, atlasPath]) {
1605
- if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
1606
- }
1607
- return { skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
1608
- }
1609
-
1610
- /** What `render` and `check` say where a rigc build's atlas is not there (issue #1020). */
1611
- const ATLAS_ABSENT = 'a build the core poses from a rigc-compiled/2 or /3 document needs none';
1612
-
1613
- /**
1614
- * The candidate `render` and `check` draw, with its atlas text — or `null`
1615
- * where the atlas file is not there and need not be (issue #1020).
1616
- *
1617
- * ⭐ A rigc build is drawn by the core from its `skeleton.model.json`, and a
1618
- * `rigc-compiled/2` document states where each region sits on its page, so
1619
- * the build's `skeleton.atlas` is not needed to draw it. Its absence is let
1620
- * through here only where it can be that case — no `--atlas` named, a model
1621
- * document beside the skeleton, and no atlas beside it at all — and the poser
1622
- * choice decides the rest: anything that is read through an atlas after all
1623
- * (a `rigc-compiled/1` document, a build the core refuses) is refused there
1624
- * naming the file and why (`CandidateAtlasError`). An atlas that IS there is
1625
- * read, as it always was: the core holds it to the document's `pages` and
1626
- * draws through spine-core, saying why, when it is not the one the build
1627
- * wrote (#1016), and `check` reads its `scale:` lines for the texture note.
1628
- * Everywhere else this is `resolveViewable`'s refusal, word for word.
1629
- */
1630
- function resolveDrawable(target: string, atlasFlag: string | undefined): { skeletonPath: string; atlasPath: string; atlasText: string | null } {
1631
- const abs = resolve(target);
1632
- if (atlasFlag === undefined && existsSync(abs)) {
1633
- const directory = statSync(abs).isDirectory();
1634
- const skeletonPath = directory ? join(abs, 'skeleton.json') : abs;
1635
- const dir = dirname(skeletonPath);
1636
- const atlasPath = join(dir, 'skeleton.atlas');
1637
- const noAtlas = directory ? !existsSync(atlasPath) : abs.endsWith('.json') && !readdirSync(dir).some((f) => f.endsWith('.atlas'));
1638
- if (noAtlas && existsSync(join(dir, MODEL_DOCUMENT_FILE))) {
1639
- if (!existsSync(skeletonPath)) throw new UsageError(`nothing at ${skeletonPath}`);
1640
- return { skeletonPath, atlasPath, atlasText: null };
1641
- }
1642
- }
1643
- const { skeletonPath, atlasPath } = resolveViewable({ candidate: target, ...(atlasFlag === undefined ? {} : { atlas: atlasFlag }) });
1644
- return { skeletonPath, atlasPath, atlasText: readFileSync(atlasPath, 'utf8') };
1645
- }
1646
-
1647
- /** `--animation`, checked against what the skeleton actually carries. */
1648
- function readAnimationFlag(flags: Record<string, string>, available: string[]): string | undefined {
1649
- const name = flags.animation;
1650
- if (name === undefined) return undefined;
1651
- if (!available.includes(name)) {
1652
- throw new UsageError(
1653
- `no animation ${JSON.stringify(name)} in this skeleton; it has [${available.join(', ') || 'none'}]`,
1654
- );
1655
- }
1656
- return name;
1657
- }
1658
-
1659
- /**
1660
- * `--skin`, checked against what the skeleton actually declares.
1661
- *
1662
- * ⭐ Absent is not `default`: it is "set no skin at all", which is what every
1663
- * render did before issue #571 and what `spine-core` starts a skeleton in. The
1664
- * distinction is the whole of the default path's byte-identity — see
1665
- * `FramesSidecar.skin`.
1666
- *
1667
- * The miss is refused by name with the declared names beside it, the way
1668
- * `--animation` is: a skin name is the one place a typo draws a whole rig's
1669
- * worth of the wrong art and reports a number about it.
1670
- */
1671
- function readSkinFlag(flags: Record<string, string>, declared: string[]): string | undefined {
1672
- const name = flags.skin;
1673
- if (name === undefined) return undefined;
1674
- if (!declared.includes(name)) {
1675
- throw new UsageError(
1676
- `no skin ${JSON.stringify(name)} in this skeleton; it declares [${declared.join(', ') || 'none'}]`,
1677
- );
1678
- }
1679
- return name;
1680
- }
1681
-
1682
- /**
1683
- * `--slot` / `--hide`, resolved against the skeleton under the skin this run
1684
- * poses it in — or refused as a usage error, nothing written (issue #835).
1685
- *
1686
- * The rule itself is `slotSubsetOf`'s in `src/render.ts`, which `piecesOf`
1687
- * applies too: it is read here only so a miss exits 2 with the usage beside
1688
- * it before a directory is created, rather than surfacing from the sampler.
1689
- */
1690
- function readSlotSubsetFlags(
1691
- flags: Record<string, string>,
1692
- facts: SkeletonFacts,
1693
- skin: string | undefined,
1694
- ): SlotSubset | undefined {
1695
- const list = (raw: string | undefined): string[] | undefined =>
1696
- raw === undefined ? undefined : raw.split(',').map((name) => name.trim()).filter((name) => name !== '');
1697
- try {
1698
- return facts.subset({ slots: list(flags.slot), hidden: list(flags.hide) }, skin);
1699
- } catch (err) {
1700
- if (err instanceof SlotSubsetError) throw new UsageError(err.message);
1701
- throw err;
1702
- }
1703
- }
1704
-
1705
- /**
1706
- * `--poser` as spelled, checked against the posers there are — shared by
1707
- * `render` and `check`. On `render` it is read where its refusal has always
1708
- * stood, after the candidate's own flags; the candidate is loaded before that
1709
- * with the spelling as given (`posersAsked`), so a rigc build the core poses
1710
- * is chosen without loading spine-core (issue #1014).
1711
- */
1712
- function readPoserFlag(flags: Record<string, string>): PoserName | undefined {
1713
- const raw = flags.poser;
1714
- const forced = POSER_NAMES.find((name) => name === raw);
1715
- if (raw !== undefined && forced === undefined) {
1716
- throw new UsageError(`--poser ${JSON.stringify(raw)}: known posers are ${POSER_NAMES.join(', ')}`);
1717
- }
1718
- return forced;
1719
- }
1720
-
1721
- /** `--poser` as the candidate is loaded with: a known spelling, or the input's own choice — the spelling is refused later, by `readPoserFlag`. */
1722
- function posersAsked(flags: Record<string, string>): PoserName | undefined {
1723
- return POSER_NAMES.find((name) => name === flags.poser);
1724
- }
1725
-
1726
- /** A resolved subset as the one field it is spelled as, in `PoseOptions` and in `frames.json` alike. */
1727
- function subsetFields(subset: SlotSubset | undefined): { slots?: string[]; hidden?: string[] } {
1728
- if (subset === undefined) return {};
1729
- return subset.mode === 'slots' ? { slots: subset.names } : { hidden: subset.names };
1730
- }
1731
-
1732
- function readPositiveNumber(flags: Record<string, string>, key: string, fallback: number, least: number): number {
1733
- const raw = flags[key];
1734
- if (raw === undefined) return fallback;
1735
- const value = Number(raw);
1736
- if (!Number.isFinite(value) || value < least) throw new UsageError(`--${key} must be a number of at least ${least}`);
1737
- return value;
1738
- }
1739
-
1740
- /**
1741
- * Does this skeleton declare a setup stage — a numeric width AND height?
1742
- *
1743
- * Read off the file's header for `preview`, which never loads one; `render`
1744
- * reads the same statement off its candidate (`SkeletonFacts.declaresStage`:
1745
- * the header again on a rigc build the core poses, the loaded `SkeletonData`
1746
- * on an export). Both are the same two fields, because
1747
- * `SkeletonJson` copies them across unconditionally (`SkeletonJson.js:70-73`),
1748
- * so a header that omits them leaves `undefined` on a field typed `number`.
1749
- */
1750
- function declaresSetupStage(header: { width?: unknown; height?: unknown }): boolean {
1751
- return typeof header.width === 'number' && typeof header.height === 'number';
1752
- }
1753
-
1754
- /** The `skeleton` block of a skeleton file's text, or an empty one where it has none. */
1755
- function skeletonHeaderOf(skeletonText: string): { width?: unknown; height?: unknown } {
1756
- const root = JSON.parse(skeletonText) as { skeleton?: { width?: unknown; height?: unknown } };
1757
- return root.skeleton ?? {};
1758
- }
1759
-
1760
- /**
1761
- * What `render` and `preview` say of their framing on a skeleton that declares
1762
- * no stage (issue #714).
1763
- *
1764
- * ⚠️ **Neither frames to a stage on ANY skeleton**, so this is not a fallback
1765
- * being announced: `framingViewport` is the union of every animation's posed
1766
- * bounds, and the Spine Web Player's `calculateAnimationViewport` samples the
1767
- * playing animation's bounds whenever its config states no viewport box, which
1768
- * `buildPreview` never does. The line is printed only where a stage is absent
1769
- * because that is the one case where a reader can ask *what box stood in for
1770
- * it* — and the answer has to be "none", said, rather than a rectangle that looks
1771
- * like a default. On a staged skeleton the output is the bytes it always was.
1772
- */
1773
- const STAGELESS_FRAMING = {
1774
- render:
1775
- 'framing the posed extent of every animation, padded — this skeleton declares no stage, and nothing stands ' +
1776
- 'in for one: render frames to the posed extent whether or not a stage is declared',
1777
- preview:
1778
- "framing the Spine Web Player's own: the posed extent of the animation it plays — this skeleton declares no " +
1779
- 'stage, and nothing stands in for one: the player frames that way whether or not a stage is declared',
1780
- } as const;
1781
-
1782
- /**
1783
- * render — the frame series, drawn by the same rasteriser `check` measures with.
1784
- *
1785
- * The framing is measured across EVERY animation at `FRAMING_FPS` and not across
1786
- * the one being written, which is `src/render.ts`'s own invariant: the viewport is
1787
- * a property of the shot, so two animations of one rig — and the same animation at
1788
- * two rates — land on one pixel grid and stay comparable.
1789
- */
1790
- function cmdRender(flags: Record<string, string>): void {
1791
- if (flags.candidate === undefined) {
1792
- throw new UsageError('needs --candidate <dir | skeleton.json> — the directory `build --out` wrote');
1793
- }
1794
- const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
1795
- const atlasDir = dirname(atlasPath);
1796
- const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
1797
- const maxSide = readPositiveNumber(flags, 'max', 256, 16);
1798
- const outRoot = resolve(flags.out ?? 'render');
1799
- const geometry = flags.geometry !== undefined;
1800
- // Refused together rather than one of them ignored (issue #864). The export
1801
- // records every slot's whole geometry, because a subset is a statement about
1802
- // which pixels are DRAWN and the pose is the same whatever a picture leaves
1803
- // out — so a geometry.json beside a subset's frames would describe slots
1804
- // those frames do not draw, and one that dropped them would stop being the
1805
- // pose. The whole-rig run writes the same file either way.
1806
- if (geometry) {
1807
- for (const flag of ['slot', 'hide'] as const) {
1808
- if (flags[flag] !== undefined) {
1809
- throw new UsageError(
1810
- `--geometry takes no --${flag}: it records every slot's whole geometry, which a subset of the drawn slots ` +
1811
- `does not change. Run \`rigc render --geometry\` on the whole rig for the file, and --${flag} ` +
1812
- `${flags[flag]} without --geometry for the pictures — both land on the same grid`,
1813
- );
1814
- }
1815
- }
1816
- }
1817
-
1818
- console.log('rigc render');
1819
- console.log(` .. skeleton ${skeletonPath}`);
1820
- console.log(` .. atlas ${atlasPath}${atlasText === null ? ` — not there (${ATLAS_ABSENT})` : ''}`);
1821
- // Which implementation of the posing seam draws this (issue #968), chosen
1822
- // before anything is read off the candidate (issue #1014): a rigc build the
1823
- // core poses — `skeleton.model.json` beside the pair — reads its names, its
1824
- // subset roster and its pages without loading spine-core at all, and with a
1825
- // rigc-compiled/2 document without its atlas either (issue #1020).
1826
- const { choice, facts, pages } = loadCandidate(
1827
- { skeletonText: readSkeletonText(skeletonPath), atlasText, atlasDir, label: skeletonPath },
1828
- { skeleton: skeletonPath, atlas: atlasPath },
1829
- posersAsked(flags),
1830
- );
1831
- const only = readAnimationFlag(flags, [...facts.animations]);
1832
- const skin = readSkinFlag(flags, [...facts.skins]);
1833
- const subset = readSlotSubsetFlags(flags, facts, skin);
1834
- // One object, so the framing and the frames cannot be posed under two
1835
- // different skins — which would frame one shot with another shot's box.
1836
- // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
1837
- // `src/render.ts` has one of its own. The inferred shape is the render one.
1838
- // The subset rides on the same object and `framingViewport` takes it off, so
1839
- // the frames draw the subset and the box is still the whole rig's.
1840
- const pose =
1841
- skin === undefined && subset === undefined
1842
- ? undefined
1843
- : { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset) };
1844
- if (skin !== undefined) console.log(` .. skin ${skin}`);
1845
- if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
1846
-
1847
- // Rigc's own core when the candidate is a rigc build, and spine-core
1848
- // otherwise, or where the core refuses the input by name. Never silently:
1849
- // the `poser` line below says which, and why. `--poser` is refused here, where
1850
- // it always was: a spelling there is no poser for, then `core` on an input
1851
- // that cannot carry it.
1852
- readPoserFlag(flags);
1853
- refuseUnchosen(choice);
1854
-
1855
- // Everything is posed before anything is written, so a core refusal partway
1856
- // re-poses the whole input on spine-core rather than leaving half of a frame
1857
- // set drawn by each.
1858
- const posed = throughPoser(choice, (poser, roster) => {
1859
- // `null` is a skeleton that posed no vertex at all. One that posed a vertex
1860
- // it cannot frame — Infinity or NaN — is thrown from the framing as a
1861
- // `GeometryError` naming the number (issue #873), and one whose every vertex
1862
- // sits at one point — every drawn bone unposed by the skin, or collapsed — as
1863
- // an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
1864
- // lets the second name the skins that pose a bone, whichever poser draws.
1865
- const viewport = framingViewport(poser, maxSide, pose, roster);
1866
- if (!viewport) {
1867
- throw new UsageError(
1868
- `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
1869
- skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
1870
- } — there is nothing to draw`,
1871
- );
1872
- }
1873
-
1874
- // `sampleAll` covers the skeleton with no animation at all, which files its one
1875
- // setup-pose frame under the reserved name. Narrowing to one animation reuses
1876
- // the same sampler rather than a second path through it.
1877
- //
1878
- // `--geometry` rides on the SAME call (issue #864): the bones and the whole
1879
- // attachments are read off the skeleton at the step that drew each frame, so
1880
- // the export's grid is this frame set's by construction.
1881
- const sampling = geometry ? { ...pose, bones: true, geometry: true } : pose;
1882
- const sampled: Map<string, Frame[]> =
1883
- only === undefined
1884
- ? sampleAll(poser, fps, sampling)
1885
- : new Map([[only, sampleAnimation(poser, only, fps, sampling)]]);
1886
- // Every file's text before the first write, so a refused number leaves the
1887
- // output directory as it was rather than half of a frame set behind it.
1888
- const geometryTexts = new Map<string, string>();
1889
- if (geometry) {
1890
- for (const [name, frames] of sampled) {
1891
- const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
1892
- geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
1893
- }
1894
- }
1895
- return { viewport, sampled, geometryTexts };
1896
- });
1897
- const { viewport, sampled, geometryTexts } = posed.value;
1898
- console.log(` .. poser ${posed.note}`);
1899
- console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
1900
- if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
1901
-
1902
- mkdirSync(outRoot, { recursive: true });
1903
- const sets: FrameSet[] = [];
1904
- for (const [name, frames] of sampled) {
1905
- // Same naming as a reference render: the protocol rate says nothing, any
1906
- // other rate says itself, so two rates of one animation sit side by side.
1907
- const dirName = fps === PROTOCOL_FPS ? name : `${name}@${fps}fps`;
1908
- const dir = join(outRoot, dirName);
1909
- // Cleared rather than written over: a shorter animation would otherwise leave
1910
- // the tail of a longer previous run on disk, and stale frames in a frame set
1911
- // are indistinguishable from real ones.
1912
- if (existsSync(dir)) rmSync(dir, { recursive: true });
1913
- mkdirSync(dir, { recursive: true });
1914
- for (let i = 0; i < frames.length; i++) {
1915
- renderFrame(frames[i], pages, viewport, BACKGROUND).writePng(join(dir, `f${String(i).padStart(4, '0')}.png`));
1916
- }
1917
- // One frame has nothing to compare itself against, so it gets no sheet — it
1918
- // would be the same picture with a border and a "0" on it.
1919
- const sheet = frames.length > 1;
1920
- if (sheet) contactSheet(frames, pages, viewport, SHEET_TILE).writePng(join(dir, SHEET_FILE));
1921
- const geometryOut = geometryTexts.get(name);
1922
- if (geometryOut !== undefined) writeFileSync(join(dir, GEOMETRY_FILE), geometryOut);
1923
- const duration = frames[frames.length - 1].time;
1924
- sets.push({
1925
- dir: dirName,
1926
- animation: name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name,
1927
- fps,
1928
- sampled: frames.length,
1929
- written: frames.length,
1930
- stride: 1,
1931
- duration,
1932
- });
1933
- const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
1934
- const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}`;
1935
- console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
1936
- }
1937
-
1938
- // The sidecar is what makes this a frame SET rather than a pile of pictures:
1939
- // the world box every frame is a picture of, so a distance measured in pixels
1940
- // converts back to the units the rig is authored in — and so `rigc check` can
1941
- // render something else into the same grid later.
1942
- const sidecar: FramesSidecar = {
1943
- spec: FRAMES_SPEC,
1944
- // Written only when a skin was asked for: absent says "no skin was set",
1945
- // which is both what this run did and what every frame set written before
1946
- // #571 did. See `FramesSidecar.skin`.
1947
- ...(skin === undefined ? {} : { skin }),
1948
- // Written only when a subset was asked for, for the same reason: a render of
1949
- // every slot says nothing and stays the bytes it always was (issue #835).
1950
- ...subsetFields(subset),
1951
- background: BACKGROUND,
1952
- // The one spelling `geometry.json` repeats, so the two files cannot state
1953
- // two boxes for one grid.
1954
- viewport: sidecarViewport(viewport),
1955
- sets: [...sets].sort((a, b) => a.dir.localeCompare(b.dir)),
1956
- };
1957
- writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
1958
- console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
1959
- }
1960
-
1961
- /** The animation names an emitted skeleton carries, in the order it lists them. */
1962
- function skeletonAnimationNames(skeletonText: string, path: string): string[] {
1963
- const parsed = parseJsonNamed(skeletonText, path);
1964
- if (typeof parsed !== 'object' || parsed === null) throw new UsageError(`${path} is not a skeleton object`);
1965
- const animations = (parsed as { animations?: unknown }).animations;
1966
- if (animations === undefined) return [];
1967
- if (typeof animations !== 'object' || animations === null || Array.isArray(animations)) {
1968
- throw new UsageError(`${path} has an "animations" field that is not an object`);
1969
- }
1970
- return Object.keys(animations);
1971
- }
1972
-
1973
- /**
1974
- * The gate's reading of one candidate, taken the way `rigc validate <dir>`
1975
- * takes it (issue #837).
1976
- *
1977
- * ⭐ The same call `cmdValidate` makes on a bare directory: the two texts, the
1978
- * atlas's own directory, the default profile, and nothing a directory cannot
1979
- * supply — no rig spec, no declared durations, no second compile. So `A09` and
1980
- * `A18` report SKIP here exactly as they do there, and the line is the line
1981
- * that command prints for these files, not the one `build` printed for the
1982
- * compile that wrote them. Measured on every run, because the page must not
1983
- * carry a figure this run did not measure.
1984
- */
1985
- function previewGate(skeletonText: string, atlasText: string, atlasDir: string): PreviewGate {
1986
- const lines = reportLines(validate({ skeletonText, atlasText, atlasDir, profile: CLI_DEFAULT_PROFILE }));
1987
- const refusal = lines.find((line) => line.startsWith(' FAIL '));
1988
- return {
1989
- // `reportLines` ends on the summary by construction; the gutter is the
1990
- // report's layout, not part of what the gate said.
1991
- summary: lines[lines.length - 1].replace(/^ {2}\.\. {4}/, ''),
1992
- refusal: refusal === undefined ? null : refusal.trimStart(),
1993
- };
1994
- }
1995
-
1996
- /**
1997
- * preview — the artifact playing in Esoteric's own web player, as one file.
1998
- *
1999
- * ⚠️ Nothing is rasterised here and nothing is decoded. The pages go into the
2000
- * page as the bytes they are on disk, so a preview works for any PNG a BROWSER
2001
- * can draw rather than for the ones our own decoder reads — which is the right
2002
- * direction for the command whose whole job is "just show me".
2003
- */
2004
- function cmdPreview(flags: Record<string, string>, candidates: string[]): void {
2005
- // Refused rather than ignored: a preview asked to hide `head` that plays the
2006
- // whole rig is a picture that answers a question it was not asked (issue #835).
2007
- for (const flag of ['slot', 'hide'] as const) {
2008
- if (flags[flag] !== undefined) {
2009
- throw new UsageError(
2010
- `preview takes no --${flag}: the Spine Web Player draws what the skeleton draws. A subset of the slots is ` +
2011
- `\`rigc render --${flag} ${flags[flag]}\`, on the whole rig's grid`,
2012
- );
2013
- }
2014
- }
2015
- const several = candidates.length > 1;
2016
- // `--atlas` names ONE atlas, and with several skeletons there is no
2017
- // unambiguous thing it could mean — the refusal `vote` makes, for the reason
2018
- // it makes it.
2019
- if (several && flags.atlas !== undefined) {
2020
- throw new UsageError(
2021
- `--atlas names one atlas and ${candidates.length} --candidate were given; each candidate's atlas has to sit ` +
2022
- 'beside its skeleton, which is what `build --out` leaves behind',
2023
- );
2024
- }
2025
- const found = several
2026
- ? candidates.map((target) => {
2027
- const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
2028
- for (const path of [skeletonPath, atlasPath]) {
2029
- if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
2030
- }
2031
- return { target, skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
2032
- })
2033
- : [{ target: flags.candidate, ...resolveViewable(flags) }];
2034
- // ⚠️ By the FILE each one resolves to, not by the text typed: `build/` and
2035
- // `build/skeleton.json` are two spellings of one candidate, and so are
2036
- // `/tmp/x` and `/private/tmp/x` on a machine where one is a link to the other
2037
- // — `resolve` alone left that pair unrefused, measured on macOS. A page
2038
- // showing one skeleton twice is two panes that look like a comparison of
2039
- // nothing. (`vote` accepts a repeat today, exit 0; that is its own card.)
2040
- const identities = found.map((f) => realpathSync(f.skeletonPath));
2041
- for (let i = 1; i < found.length; i++) {
2042
- const first = identities.indexOf(identities[i]);
2043
- if (first < i) {
2044
- throw new UsageError(
2045
- `--candidate ${JSON.stringify(found[i].target)} is ${identities[i]}, which --candidate ` +
2046
- `${JSON.stringify(found[first].target)} already names (candidates ${first + 1} and ${i + 1}); a pane per ` +
2047
- 'candidate would show the same skeleton twice',
2048
- );
2049
- }
2050
- }
2051
-
2052
- // Every candidate's texts and animation are read before a line is printed,
2053
- // so a refusal about any of them comes before the report, as it always has.
2054
- const loaded = found.map((f, i) => {
2055
- const skeletonText = readFileSync(f.skeletonPath, 'utf8');
2056
- const atlasText = readFileSync(f.atlasPath, 'utf8');
2057
- const animations = skeletonAnimationNames(skeletonText, f.skeletonPath);
2058
- let chosen: string | undefined;
2059
- try {
2060
- chosen = readAnimationFlag(flags, animations);
2061
- } catch (err) {
2062
- // With several candidates the refusal has to say WHICH one lacks it.
2063
- if (several && err instanceof UsageError) {
2064
- throw new UsageError(`candidate ${i + 1} (${f.skeletonPath}): ${err.message}`);
2065
- }
2066
- throw err;
2067
- }
2068
- return { ...f, skeletonText, atlasText, animations, chosen };
2069
- });
2070
-
2071
- // A directory for --out is taken as "put the default name in here", because
2072
- // `--out render/` is what the sibling command means by the same flag and a
2073
- // preview written OVER a directory is not a recoverable mistake.
2074
- const target = resolve(flags.out ?? 'preview.html');
2075
- const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
2076
- const version = readVersion();
2077
-
2078
- console.log('rigc preview');
2079
- const inputs: PreviewInput[] = loaded.map((candidate, i) => {
2080
- const { skeletonPath, atlasPath, atlasDir, skeletonText, atlasText, animations, chosen } = candidate;
2081
- if (several) console.log(` .. pane ${i + 1} of ${loaded.length}`);
2082
- console.log(` .. skeleton ${skeletonPath}`);
2083
- console.log(` .. atlas ${atlasPath}`);
2084
- const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
2085
- const path = join(atlasDir, name);
2086
- if (!existsSync(path)) {
2087
- throw new UsageError(
2088
- `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
2089
- 'a page a preview cannot embed is a page the player could not have loaded either',
2090
- );
2091
- }
2092
- return { name, bytes: readFileSync(path) };
2093
- });
2094
- for (const page of pages) {
2095
- console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
2096
- }
2097
- if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
2098
- const gate = previewGate(skeletonText, atlasText, atlasDir);
2099
- console.log(` .. gate ${gate.summary}`);
2100
- if (gate.refusal !== null) {
2101
- console.log(
2102
- ` .. gate refused — ${gate.refusal}. Previewed anyway: looking at a red build is what preview is ` +
2103
- 'for, and the page header says the same',
2104
- );
2105
- }
2106
- return {
2107
- skeletonText,
2108
- atlasText,
2109
- pages,
2110
- animation: chosen ?? animations[0] ?? null,
2111
- animations,
2112
- label: skeletonPath,
2113
- version,
2114
- gate,
2115
- };
2116
- });
2117
-
2118
- const html = several ? buildPreviewPanes(inputs, version) : buildPreview(inputs[0]);
2119
- mkdirSync(dirname(out), { recursive: true });
2120
- writeFileSync(out, html);
2121
- const pageCount = inputs.reduce((n, input) => n + input.pages.length, 0);
2122
- console.log(
2123
- several
2124
- ? ` .. embedded ${pageCount} page(s) + ${inputs.length} skeletons and atlases as data URIs, one pane each; ` +
2125
- `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`
2126
- : ` .. embedded ${pageCount} page(s) + the skeleton and atlas as data URIs; ` +
2127
- `the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
2128
- );
2129
- console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
2130
- }
2131
-
2132
- // ---------------------------------------------------------------------------
2133
- // reading a given condition — pose
2134
- // ---------------------------------------------------------------------------
2135
- //
2136
- // ⭐ Every other command here takes a spec and looks at what came out. This one
2137
- // runs the other way: it takes a PICTURE the user already has — a key pose — and
2138
- // reads spec coordinates out of it, so an agent can state those poses in a rig and
2139
- // a motion by construction and spend its loops on the part nobody can measure, the
2140
- // movement between them.
2141
- //
2142
- // 🚫 It grades nothing, and the distinction is load-bearing rather than modest.
2143
- // `check` and `bench` compare a build against a reference and their numbers mean
2144
- // "how close"; a pose frame is not a reference, it is an INPUT, and once the spec
2145
- // states it there is nothing left to be close to. So the residual here is a trust
2146
- // signal — how much of the frame this placement actually explains — and the only
2147
- // threshold in `src/pose.ts` is the one that decides whether to print an answer at
2148
- // all, which the caller can move.
2149
- //
2150
- // rigc pose --images parts/ --frame poseA.png [--out pose.json]
2151
-
2152
- const DEFAULT_POSE_OUT = 'pose.json';
2153
-
2154
- /** `--scale 0.5,2` / `--rotation -30,30` — a pair of numbers, low first. */
2155
- function readRange(flags: Record<string, string>, key: string): { low: number; high: number } | undefined {
2156
- const raw = flags[key];
2157
- if (raw === undefined) return undefined;
2158
- const parts = raw.split(',').map((s) => Number(s.trim()));
2159
- if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n))) {
2160
- throw new UsageError(`--${key} takes two numbers: <min>,<max>`);
2161
- }
2162
- if (parts[1] < parts[0]) throw new UsageError(`--${key} ${JSON.stringify(raw)}: the minimum must not exceed the maximum`);
2163
- return { low: parts[0], high: parts[1] };
2164
- }
2165
-
2166
- function cmdPose(flags: Record<string, string>): void {
2167
- if (flags.images === undefined) throw new UsageError('pose needs --images <dir> — the directory the loose part PNGs are in');
2168
- if (flags.frame === undefined) throw new UsageError('pose needs --frame <path> — one pose frame to read the placements out of');
2169
- const options: PoseOptions = { imagesDir: flags.images, framePath: flags.frame };
2170
- const scale = readRange(flags, 'scale');
2171
- if (scale) {
2172
- if (scale.low <= 0) throw new UsageError('--scale minimum must be greater than zero');
2173
- options.scale = { min: scale.low, max: scale.high };
2174
- }
2175
- const rotation = readRange(flags, 'rotation');
2176
- if (rotation) {
2177
- if (rotation.high - rotation.low > 360) throw new UsageError('--rotation cannot span more than a full turn');
2178
- options.rotation = { minDeg: rotation.low, maxDeg: rotation.high };
2179
- }
2180
- if (flags['max-residual'] !== undefined) {
2181
- const value = Number(flags['max-residual']);
2182
- if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--max-residual must be a number in (0, 1]');
2183
- options.maxResidual = value;
2184
- }
2185
-
2186
- console.log('rigc pose');
2187
- const report = estimatePose(options);
2188
- for (const line of poseLines(report)) console.log(line);
2189
-
2190
- // Same `--out` shape as `preview` and `vote`: one file, and a directory means
2191
- // "the default name in here" rather than a report written over a directory.
2192
- const target = resolve(flags.out ?? DEFAULT_POSE_OUT);
2193
- const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_POSE_OUT) : target;
2194
- writeJson(out, report);
2195
- }
2196
-
2197
- // ---------------------------------------------------------------------------
2198
- // reading the half a picture hides — chainfit
2199
- // ---------------------------------------------------------------------------
2200
- //
2201
- // ⭐ `pose` above reads a picture with nothing but the loose parts, and refuses
2202
- // the parts another part is drawn over — a residual measured through an occluder
2203
- // rises AT the correct placement, so the honest answer is a refusal. This reads
2204
- // those, and the whole difference is that it is also given the CANDIDATE: with a
2205
- // draw order the covered pixels can be excluded from a part's objective instead
2206
- // of charged to it, and with a hierarchy a child of a placed bone has one degree
2207
- // of freedom — the hinge about its own pivot — where `pose` has four.
2208
- //
2209
- // 🚫 Same phase and the same framing as `pose`: it reads a given condition into
2210
- // spec coordinates and grades nothing. Every residual is a trust signal, every
2211
- // threshold is reported, and `visibleShare` is how much of the part the number
2212
- // was even computed on.
2213
- //
2214
- // rigc chainfit --candidate <dir> --images <dir> --frame poseA.png [--anchor pose.json]
2215
-
2216
- const DEFAULT_CHAINFIT_OUT = 'chainfit.json';
2217
-
2218
- function cmdChainFit(flags: Record<string, string>): void {
2219
- if (flags.candidate === undefined) {
2220
- throw new UsageError('chainfit needs --candidate <dir | skeleton.json> — the compiled rig to read the frame through');
2221
- }
2222
- if (flags.images === undefined) {
2223
- throw new UsageError("chainfit needs --images <dir> — where the candidate's attachment image names resolve to PNGs");
2224
- }
2225
- if (flags.frame === undefined) throw new UsageError('chainfit needs --frame <path> — one pose frame to read the placements out of');
2226
- // Refused rather than ignored. Every other --candidate command takes --atlas, so
2227
- // passing it here is a reasonable thing to try — and a flag that silently does
2228
- // nothing is worse than one that says why it cannot.
2229
- if (flags.atlas !== undefined) {
2230
- throw new UsageError(
2231
- 'chainfit reads no atlas: the part art comes from --images, one PNG per attachment image name, and the ' +
2232
- 'skeleton is all it needs of the candidate. Drop --atlas',
2233
- );
2234
- }
2235
- const options: ChainFitOptions = {
2236
- candidatePath: flags.candidate,
2237
- imagesDir: flags.images,
2238
- framePath: flags.frame,
2239
- };
2240
- if (flags.anchor !== undefined) options.anchorPath = flags.anchor;
2241
- const hinge = readRange(flags, 'hinge');
2242
- if (hinge) {
2243
- if (hinge.high - hinge.low > 360) throw new UsageError('--hinge cannot span more than a full turn');
2244
- options.hinge = { minDeg: hinge.low, maxDeg: hinge.high };
2245
- }
2246
- if (flags.stretch !== undefined) {
2247
- const value = Number(flags.stretch);
2248
- if (!Number.isFinite(value) || value < 1) throw new UsageError('--stretch must be a ratio of 1 or more, e.g. 1.25');
2249
- options.stretch = value;
2250
- }
2251
- if (flags['min-visible'] !== undefined) {
2252
- const value = Number(flags['min-visible']);
2253
- if (!Number.isFinite(value) || value < 0 || value > 1) throw new UsageError('--min-visible must be a number in [0, 1]');
2254
- options.minVisible = value;
2255
- }
2256
- if (flags['max-residual'] !== undefined) {
2257
- const value = Number(flags['max-residual']);
2258
- if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--max-residual must be a number in (0, 1]');
2259
- options.maxResidual = value;
2260
- }
2261
- if (flags.passes !== undefined) {
2262
- const value = Number(flags.passes);
2263
- if (!Number.isInteger(value) || value < 1 || value > 8) throw new UsageError('--passes must be a whole number in 1..8');
2264
- options.passes = value;
2265
- }
2266
- if (flags['inward-lever'] !== undefined) {
2267
- const value = Number(flags['inward-lever']);
2268
- if (!Number.isFinite(value) || value < 0) throw new UsageError('--inward-lever must be a number of frame pixels, 0 or more');
2269
- options.minLeverPx = value;
2270
- }
2271
- if (flags['anchor-residual'] !== undefined) {
2272
- const value = Number(flags['anchor-residual']);
2273
- if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--anchor-residual must be a number in (0, 1]');
2274
- options.anchorMaxResidual = value;
2275
- }
2276
- const scale = readRange(flags, 'scale');
2277
- if (scale) {
2278
- if (scale.low <= 0) throw new UsageError('--scale minimum must be greater than zero');
2279
- options.scale = { min: scale.low, max: scale.high };
2280
- }
2281
- const rotation = readRange(flags, 'rotation');
2282
- if (rotation) {
2283
- if (rotation.high - rotation.low > 360) throw new UsageError('--rotation cannot span more than a full turn');
2284
- options.rotation = { minDeg: rotation.low, maxDeg: rotation.high };
2285
- }
2286
-
2287
- console.log('rigc chainfit');
2288
- const report = estimateChainFit(options);
2289
- for (const line of chainFitLines(report)) console.log(line);
2290
-
2291
- const target = resolve(flags.out ?? DEFAULT_CHAINFIT_OUT);
2292
- const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_CHAINFIT_OUT) : target;
2293
- writeJson(out, report);
2294
- }
2295
-
2296
- // ---------------------------------------------------------------------------
2297
- // choosing between results — vote
2298
- // ---------------------------------------------------------------------------
2299
- //
2300
- // ⭐ `preview` shows candidates and asks nothing; this shows two to four of them
2301
- // side by side, hides where each came from, and takes an answer back. The rest of this toolchain is instruments, and it
2302
- // should be — the vote opens only where the instruments have already run out.
2303
- // See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
2304
- // why the labels are A and B, and why the record is hashes.
2305
- //
2306
- // Two modes on one command, because they share exactly one thing and it is the
2307
- // contract between them: the ballot manifest. Splitting them would document
2308
- // that format twice and let the halves drift.
2309
- //
2310
- // rigc vote --candidate <a> --candidate <b> [--animation <n>] [--out ballot.html]
2311
- // rigc vote --record <result.json> [--ballot ballot.html] [--ledger votes.jsonl] [--again]
2312
-
2313
- const DEFAULT_BALLOT = 'ballot.html';
2314
- const DEFAULT_LEDGER = 'votes.jsonl';
2315
-
2316
- /** Load one candidate off disk in the shape a ballot needs. */
2317
- function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
2318
- const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
2319
- for (const path of [skeletonPath, atlasPath]) {
2320
- if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
2321
- }
2322
- const skeletonText = readFileSync(skeletonPath, 'utf8');
2323
- const atlasText = readFileSync(atlasPath, 'utf8');
2324
- const atlasDir = dirname(atlasPath);
2325
- const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
2326
- const path = join(atlasDir, name);
2327
- if (!existsSync(path)) {
2328
- throw new UsageError(
2329
- `the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
2330
- 'a page a ballot cannot embed is a page the player could not have loaded either',
2331
- );
2332
- }
2333
- return { name, bytes: readFileSync(path) };
2334
- });
2335
- return {
2336
- candidate: { source: skeletonPath, skeletonText, atlasText, pages },
2337
- animations: skeletonAnimationNames(skeletonText, skeletonPath),
2338
- };
2339
- }
2340
-
2341
- /**
2342
- * The one animation every candidate plays.
2343
- *
2344
- * ⚠️ Refused rather than resolved per candidate. Two panes running two
2345
- * different animations look like a comparison and are not one, and a voter has
2346
- * no way to see that it happened — the labels are `A` and `B`, which is the
2347
- * whole point, so nothing on the screen would say so.
2348
- */
2349
- function commonAnimation(
2350
- flags: Record<string, string>,
2351
- loaded: { animations: string[] }[],
2352
- ): string | null {
2353
- const asked = flags.animation;
2354
- if (asked === undefined) {
2355
- const first = loaded[0].animations[0];
2356
- if (first === undefined) {
2357
- const withAny = loaded.findIndex((l) => l.animations.length > 0);
2358
- if (withAny !== -1) {
2359
- throw new UsageError(
2360
- `candidate ${withAny + 1} has animations [${loaded[withAny].animations.join(', ')}] and candidate 1 has none — ` +
2361
- 'a ballot plays one animation in every pane, so there is nothing to compare here',
2362
- );
2363
- }
2364
- return null;
2365
- }
2366
- const missing = loaded.findIndex((l) => !l.animations.includes(first));
2367
- if (missing !== -1) {
2368
- throw new UsageError(
2369
- `the default animation is candidate 1's first, ${JSON.stringify(first)}, and candidate ${missing + 1} does not ` +
2370
- `have it (it has [${loaded[missing].animations.join(', ') || 'none'}]); name one they share with --animation`,
2371
- );
2372
- }
2373
- return first;
2374
- }
2375
- const missing = loaded.findIndex((l) => !l.animations.includes(asked));
2376
- if (missing !== -1) {
2377
- throw new UsageError(
2378
- `no animation ${JSON.stringify(asked)} in candidate ${missing + 1}; it has ` +
2379
- `[${loaded[missing].animations.join(', ') || 'none'}]`,
2380
- );
2381
- }
2382
- return asked;
2383
- }
2384
-
2385
- /** vote (ballot mode) — write the page a human opens. */
2386
- function cmdVoteBallot(flags: Record<string, string>, candidates: string[]): void {
2387
- if (candidates.length < MIN_CANDIDATES) {
2388
- throw new UsageError(
2389
- `a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} --candidate <dir | skeleton.json>, and ${candidates.length} ` +
2390
- 'was given — one candidate on its own is `rigc preview`',
2391
- );
2392
- }
2393
- if (candidates.length > MAX_CANDIDATES) {
2394
- throw new UsageError(
2395
- `${candidates.length} candidates were given and a ballot holds at most ${MAX_CANDIDATES} — they go side by side ` +
2396
- 'on one screen, and a comparison that needs scrolling is not a comparison',
2397
- );
2398
- }
2399
- // `--atlas` names ONE atlas and there are several skeletons here, so there is
2400
- // no unambiguous thing it could mean. Each candidate's atlas has to sit beside
2401
- // its skeleton, which is what `build --out` leaves behind.
2402
- if (flags.atlas !== undefined) {
2403
- throw new UsageError(
2404
- '--atlas names one atlas and a ballot has several candidates; each one\'s atlas has to sit beside its skeleton',
2405
- );
2406
- }
2407
-
2408
- const loaded = candidates.map((target) => loadBallotCandidate(target));
2409
- const animation = commonAnimation(flags, loaded);
2410
-
2411
- const target = resolve(flags.out ?? DEFAULT_BALLOT);
2412
- const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_BALLOT) : target;
2413
-
2414
- const input: BallotInput = {
2415
- candidates: loaded.map((l) => l.candidate),
2416
- animation,
2417
- version: readVersion(),
2418
- };
2419
- const { html, manifest } = buildBallot(input);
2420
-
2421
- console.log('rigc vote');
2422
- console.log(` .. ballot ${manifest.ballot}`);
2423
- console.log(` .. animation ${animation === null ? '(none — the setup pose)' : animation}`);
2424
- for (let i = 0; i < manifest.candidates.length; i++) {
2425
- const entry = manifest.candidates[i];
2426
- const bytes = loaded[i].candidate.pages.reduce((n, p) => n + p.bytes.length, 0);
2427
- console.log(
2428
- ` .. ${entry.label} ${entry.digest.slice(0, 'sha256:'.length + 12)}… ` +
2429
- `${entry.pages.length} page(s), ${(bytes / 1024).toFixed(1)} KiB <- ${entry.source}`,
2430
- );
2431
- }
2432
- mkdirSync(dirname(out), { recursive: true });
2433
- writeFileSync(out, html);
2434
- console.log(
2435
- ` .. the page shows ${manifest.candidates.map((c) => c.label).join('/')} and nothing else — the paths above are ` +
2436
- 'in its manifest, never on the screen',
2437
- );
2438
- console.log(
2439
- ` .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from ` +
2440
- `unpkg (@${PLAYER_LINE}), so the first open needs a network`,
2441
- );
2442
- console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
2443
- console.log(
2444
- `rigc: then record the saved vote with rigc vote --record ${resultFilename(manifest.ballot)} --ballot ${out}`,
2445
- );
2446
- }
2447
-
2448
- /** vote (record mode) — check one saved vote and append it to the ledger. */
2449
- function cmdVoteRecord(flags: Record<string, string>): void {
2450
- for (const key of ['candidate', 'out'] as const) {
2451
- if (flags[key] !== undefined) {
2452
- throw new UsageError(`--record and --${key} are the two halves of this command; run them one at a time`);
2453
- }
2454
- }
2455
- const resultPath = resolve(flags.record);
2456
- const ballotPath = resolve(flags.ballot ?? DEFAULT_BALLOT);
2457
- const ledgerPath = resolve(flags.ledger ?? DEFAULT_LEDGER);
2458
- for (const [what, path] of [
2459
- ['result', resultPath],
2460
- ['ballot', ballotPath],
2461
- ] as const) {
2462
- if (!existsSync(path)) {
2463
- throw new UsageError(
2464
- `no ${what} file at ${path}` + (what === 'ballot' ? ' — name the page this vote came from with --ballot' : ''),
2465
- );
2466
- }
2467
- }
2468
-
2469
- console.log('rigc vote --record');
2470
- console.log(` .. result ${resultPath}`);
2471
- console.log(` .. ballot ${ballotPath}`);
2472
- console.log(` .. ledger ${ledgerPath}`);
2473
-
2474
- const manifest = readBallotManifest(readFileSync(ballotPath, 'utf8'), ballotPath);
2475
- const result = readJsonFile(resultPath);
2476
- const existing = existsSync(ledgerPath) ? parseLedger(readFileSync(ledgerPath, 'utf8'), ledgerPath) : [];
2477
- const attempts = existing.filter((l) => l.ballot === manifest.ballot).length;
2478
- const again = flags.again !== undefined;
2479
-
2480
- const { refusals, line } = verifyResult(manifest, result, { attempts, again });
2481
- if (line === null) {
2482
- for (const refusal of refusals) console.error(` FAIL ${refusal.rule}: ${refusal.detail}`);
2483
- console.error(`rigc: ${refusals.length} refusal(s) — nothing appended to ${ledgerPath}`);
2484
- process.exit(1);
2485
- }
2486
- for (const rule of VOTE_RULES) console.log(` PASS ${rule}`);
2487
-
2488
- line.seq = existing.length + 1;
2489
- mkdirSync(dirname(ledgerPath), { recursive: true });
2490
- appendFileSync(ledgerPath, ledgerLineText(line));
2491
- console.log(
2492
- ` .. ${line.choice === TIE ? 'tie' : `winner ${line.choice} = ${line.winner}`}, ` +
2493
- `reason code ${line.reasonCode}${line.attempt > 1 ? `, attempt ${line.attempt}` : ''}`,
2494
- );
2495
- console.log(
2496
- ` .. coverage ${line.coverage.length} candidate(s): ` +
2497
- line.coverage.map((c) => `${c.label}=${c.digest.slice(0, 'sha256:'.length + 12)}…`).join(' '),
2498
- );
2499
- console.log(`rigc: appended line ${line.seq} to ${ledgerPath}`);
2500
- }
2501
-
2502
- function cmdVote(flags: Record<string, string>, candidates: string[]): void {
2503
- if (flags.record !== undefined) cmdVoteRecord(flags);
2504
- else if (candidates.length > 0) cmdVoteBallot(flags, candidates);
2505
- else {
2506
- throw new UsageError(
2507
- 'vote takes either 2–4 --candidate <dir | skeleton.json> to write a ballot, or --record <result.json> to ' +
2508
- 'record one that came back',
2509
- );
2510
- }
2511
- }
2512
-
2513
- /**
2514
- * bench — run one rung of the benchmark ladder against a candidate rig.
2515
- *
2516
- * Two questions, asked in this order and never merged:
2517
- *
2518
- * 1. Is the candidate valid Spine at all? That is `validate --profile spine`,
2519
- * and it is the only part with a pass/fail. The profile is pinned here, not
2520
- * inherited from the CLI default: the thing being reproduced is an editor
2521
- * export, and holding it to this project's renderer policy would fail rungs
2522
- * for reasons the rung is not about.
2523
- * 2. How close is it, structurally, to the reference? That is `diff`, and it
2524
- * has no threshold at all. There is no score to pass, on purpose — see
2525
- * `src/diff.ts`. A rung is called cleared by a human reading the measures,
2526
- * and `docs/LADDER.md` records that judgement.
2527
- *
2528
- * ⚠️ The candidate is validated against the SPINE profile and compared against
2529
- * the reference; the reference is never validated here. It is editor output and
2530
- * is the definition of correct for this exercise, so gating it would be gating
2531
- * the yardstick with the ruler.
2532
- */
2533
- function cmdBench(flags: Record<string, string>, positional: string[]): void {
2534
- const rungId = positional[0];
2535
- if (!rungId) throw new UsageError(`bench takes a rung: ${RUNG_IDS.join(' | ')}`);
2536
- const rung = findRung(rungId);
2537
- if (!rung) throw new UsageError(`unknown rung ${JSON.stringify(rungId)}; known: ${RUNG_IDS.join(', ')}`);
2538
- if (flags.candidate === undefined) throw new UsageError('bench needs --candidate <dir | skeleton.json>');
2539
-
2540
- // bench judges a reproduction of editor output, so `spine` is PINNED here
2541
- // rather than inherited. It reads the same as the CLI default today (#221) and
2542
- // is kept as its own statement anyway: the ladder's stage-1 gate is defined by
2543
- // `docs/GATE.md` as `validate --profile spine`, and a bench run must go on
2544
- // meaning that whatever a later release decides the default should be.
2545
- const profile: ValidateProfile = flags.profile === undefined ? 'spine' : readProfile(flags);
2546
- const exportDir = resolve(import.meta.dir, 'examples', rung.example, 'export');
2547
- if (!existsSync(exportDir)) {
2548
- // `bun run fetch-examples` runs `scripts/fetch-examples.sh`, and `scripts/`
2549
- // is not in package.json's `files` — an npm install has no such script to
2550
- // run. Its presence is what tells the two contexts apart, so the remedy
2551
- // named here is one that actually exists in whichever context this is.
2552
- const remedy = existsSync(resolve(import.meta.dir, 'scripts', 'fetch-examples.sh'))
2553
- ? 'run `bun run fetch-examples` first'
2554
- : `bench needs a checkout of ${repositoryUrl()} — its \`fetch-examples\` script is not part of the installed package`;
2555
- throw new UsageError(`no example corpus at ${exportDir} — ${remedy} (examples/ is gitignored, not shipped)`);
2556
- }
2557
-
2558
- // Both files there and the skeleton JSON before the first line is printed, refused by name as `render` refuses
2559
- // them (issue #1042): a missing file and a skeleton that is not JSON surfaced as an ENOENT or a SyntaxError and a
2560
- // stack, the second only after the validate block had printed.
2561
- const { skeletonPath, atlasPath } = resolveViewable({ candidate: flags.candidate, ...(flags.atlas === undefined ? {} : { atlas: flags.atlas }) });
2562
- const skeletonText = readSkeletonText(skeletonPath);
2563
- const atlasText = readFileSync(atlasPath, 'utf8');
2564
-
2565
- console.log(`rigc bench rung ${rung.id} — ${rung.example}`);
2566
- console.log(` gates ${rung.gates}`);
2567
- console.log(` candidate ${skeletonPath}`);
2568
- console.log(` atlas ${atlasPath}`);
2569
- console.log('');
2570
-
2571
- console.log(` ── validate (profile ${profile}) ──`);
2572
- const report = validate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), profile });
2573
- for (const line of reportLines(report)) console.log(` ${line}`);
2574
- console.log('');
2575
-
2576
- const candidateJson: unknown = JSON.parse(skeletonText);
2577
- const diffs: Array<{ skeleton: RungSkeleton; reference: string; report: DiffReport }> = [];
2578
- for (const skeleton of rung.skeletons) {
2579
- const referencePath = join(exportDir, skeleton.file);
2580
- if (!existsSync(referencePath)) {
2581
- console.error(` MISSING ${referencePath} — re-run \`bun run fetch-examples\``);
2582
- continue;
2583
- }
2584
- const role = skeleton.role === 'stretch' ? ' (stretch — reported, does not count)' : '';
2585
- console.log(` ── diff vs ${rung.example}/${skeleton.label}${role} ──`);
2586
- const diff = diffSkeletons(candidateJson, JSON.parse(readFileSync(referencePath, 'utf8')));
2587
- for (const line of diffLines(diff, { candidate: skeletonPath, reference: referencePath })) console.log(` ${line}`);
2588
- console.log('');
2589
- diffs.push({ skeleton, reference: referencePath, report: diff });
2590
- }
2591
-
2592
- // Stage 3, optional and behind a flag because the correspondence is an INPUT:
2593
- // a candidate is entitled to its own bone names, so there is nothing sensible
2594
- // to default to and a derived mapping would be a guess reported as a
2595
- // measurement (issue #8). Nothing here gates, and without the flag the report
2596
- // above is unchanged to the byte.
2597
- const boneDists: Array<{ skeleton: RungSkeleton; report: BoneDistReport }> = [];
2598
- if (flags.bones !== undefined) {
2599
- for (const skeleton of rung.skeletons) {
2600
- const referencePath = join(exportDir, skeleton.file);
2601
- if (!existsSync(referencePath)) continue;
2602
- console.log(` ── bonedist vs ${rung.example}/${skeleton.label} (stage 3) ──`);
2603
- const boneDist = boneDistance({
2604
- candidateSkeleton: skeletonPath,
2605
- candidateAtlas: atlasPath,
2606
- referenceSkeleton: referencePath,
2607
- referenceAtlas: join(exportDir, skeleton.atlas),
2608
- bones: flags.bones,
2609
- // Deliberately NOT `flags.fps`. Inside `bench` that flag already means
2610
- // "the rate this frame set was recorded at, for a set with no sidecar",
2611
- // and one flag doing two unrelated things in one command is how a
2612
- // reader ends up quoting a figure measured at a rate they did not ask
2613
- // for. A run wanting another sampling rate calls `rigc bonedist`, where
2614
- // `--fps` has exactly one meaning.
2615
- });
2616
- for (const line of boneDistLines(boneDist, { allBones: flags['all-bones'] !== undefined })) console.log(` ${line}`);
2617
- console.log('');
2618
- boneDists.push({ skeleton, report: boneDist });
2619
- }
2620
- }
2621
-
2622
- // Third, optional and third for a reason: is it the same MOTION? `diff`
2623
- // compares structure, and a reversed easing is the same key count and the same
2624
- // curve kind — so a row of this ladder carrying only `validate` and `diff`
2625
- // records a rig that could be animated backwards. `--frames` folds `check`'s
2626
- // table into the report so a future row carries both.
2627
- let check: CheckReport | null = null;
2628
- if (flags.frames !== undefined) {
2629
- console.log(` ── check vs frames ${resolve(flags.frames)} ──`);
2630
- check = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
2631
- for (const line of checkLines(check, { allFrames: flags['all-frames'] !== undefined })) console.log(` ${line}`);
2632
- console.log('');
2633
- }
2634
-
2635
- console.log(' ── summary ──');
2636
- console.log(` validate ${report.failures.length === 0 ? 'green' : `${report.failures.length} FAILED`} (profile ${profile})`);
2637
- for (const d of diffs) {
2638
- const means = d.report.sections.map((s) => `${s.name}=${s.ratio.toFixed(3)}`).join(' ');
2639
- console.log(` ${d.skeleton.label.padEnd(10)} ${means}${d.skeleton.role === 'stretch' ? ' [stretch]' : ''}`);
2640
- // Second line, not folded into the first: the figures above are the ones
2641
- // every bench.json on disk already carries, and a ladder record is worth
2642
- // less the moment its headline stops meaning what the older ones meant.
2643
- // The sections whose measures are dominated by name-keyed ones get their
2644
- // name-agnostic figure printed beside — issue #21.
2645
- const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
2646
- if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
2647
- // A third line, for the same reason the second one is not folded into the
2648
- // first: the reported measures are unobservable by construction, so they
2649
- // roll into no mean at all and cannot be shown as one. Each is named with
2650
- // its own figure — a per-section digest would be the mean this block exists
2651
- // to refuse.
2652
- const reported = reportedFigures(d.report);
2653
- if (reported !== null) console.log(` ${''.padEnd(10)} reported: ${reported}`);
2654
- }
2655
- if (check) {
2656
- // The framing goes first because it is upstream of every MAE below it: a
2657
- // summary that reported those numbers without saying how the two shots were
2658
- // put on each other is how issue #34 stayed invisible for two ladder runs.
2659
- const framing = check.framingFit;
2660
- if (!framing && check.sharedFraming) {
2661
- const f = check.sharedFraming.fit;
2662
- console.log(
2663
- ` framing one per set (${check.animations.length}); one shared box leaves ` +
2664
- `x${f.scale.toFixed(6)}, rms ${f.rms.toFixed(2)}px — see the check table above for each set's own`,
2665
- );
2666
- }
2667
- if (framing) {
2668
- const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
2669
- const how = !framing.applied
2670
- ? 'measured only — --viewport pinned'
2671
- : framing.source === 'declared'
2672
- ? `frames.json's own box, the candidate measured into it`
2673
- : `fitted to the candidate's pixels, ${framing.passes} pass(es)${framing.settled ? '' : framing.cycled ? ', cycling' : ', unsettled'}`;
2674
- // The MAE-refined offset belongs on this line rather than only in `check`'s
2675
- // own table: it moved the box every figure below was measured in, so a row
2676
- // that quoted the figures without it would not say what they were measured
2677
- // against — issue #146's own version of the #34 lesson above.
2678
- const r = framing.refinement;
2679
- const refined =
2680
- r === null || !r.applied
2681
- ? ''
2682
- : ` MAE-refined ${signed(r.dx)}, ${signed(r.dy)}px (${r.before.toFixed(2)} → ${r.after.toFixed(2)} ref)`;
2683
- console.log(
2684
- ` framing fit x${framing.fit.scale.toFixed(6)} rms ${framing.fit.rms.toFixed(2)}px union residual ` +
2685
- `${signed(framing.fit.residualWidth)} x ${signed(framing.fit.residualHeight)}px (${how})${refined}`,
2686
- );
2687
- }
2688
- for (const anim of check.animations) {
2689
- const attributed = anim.compared - anim.framesWithoutDrift;
2690
- const drift =
2691
- anim.worstDriftFrame < 0
2692
- ? 'no slot attributable in any of them'
2693
- : `worst slot drift ${anim.worstDrift.toFixed(1)}px, attributed in ${attributed}`;
2694
- // The per-frame change count is carried here and not only in `check`'s own
2695
- // table because it is the one figure a flat MAE cannot imply: a shot can be
2696
- // right at every frame and still hold or blink at the wrong moments.
2697
- const change =
2698
- anim.changeDisagreements === 0
2699
- ? ''
2700
- : `, ${anim.changeDisagreements}/${anim.changePairs} pair(s) change unlike the reference`;
2701
- // Which of the candidate's own bone chains the error is in — one name, so a
2702
- // loop between builds reads a unit to fix rather than a verdict on the shot.
2703
- // The full table is in `check`'s own report; this is its headline.
2704
- const worstChain = [...anim.chains].sort((a, b) => b.maeShare - a.maeShare)[0];
2705
- const chain =
2706
- worstChain === undefined ? '' : `, ${worstChain.chain} carries ${(worstChain.maeShare * 100).toFixed(0)}%`;
2707
- // `ref=` is the same difference over the reference's own drawn pixels. It is
2708
- // carried here and not only in `check`'s own table because this is the line a
2709
- // loop reads between builds, and `mean=` has a denominator the candidate can
2710
- // grow — see `FrameCheck.maeReference`.
2711
- // ...and the contact sheet, when the set ships one: a row reading "over 2
2712
- // frame(s)" for a 311-frame shot is the hole issue #36 closed, and the whole
2713
- // -shot figure is the one that says the frames between the stills were seen.
2714
- const sheet =
2715
- anim.sheet === null
2716
- ? ''
2717
- : `, sheet ${anim.sheet.compared} tile(s) mean=${anim.sheet.meanMae.toFixed(2)} ` +
2718
- `worst=${anim.sheet.worstMae.toFixed(2)}`;
2719
- console.log(
2720
- ` ${anim.dir.padEnd(10)} MAE mean=${anim.meanMae.toFixed(2)} worst=${anim.worstMae.toFixed(2)} ` +
2721
- `ref=${anim.meanMaeReference.toFixed(2)} over ${anim.compared} frame(s) ${drift}${change}${chain}${sheet}`,
2722
- );
2723
- }
2724
- } else {
2725
- console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
2726
- console.log(' Without it this report says nothing about whether the ANIMATION is right.');
2727
- }
2728
- if (boneDists.length > 0) {
2729
- for (const b of boneDists) {
2730
- const w = b.report.worst;
2731
- console.log(
2732
- ` ${b.skeleton.label.padEnd(10)} bonedist worst position ${w.position.value.toFixed(6)} skeleton-size(s), ` +
2733
- `rotation ${w.rotation.value.toFixed(4)}°, scale ${w.scale.value.toFixed(6)}, linear ${w.linear.value.toFixed(6)} ` +
2734
- `over ${b.report.animations.reduce((n, a) => n + a.compared, 0)} frame(s) × ${b.report.correspondence.pairs} bone pair(s)`,
2735
- );
2736
- }
2737
- } else {
2738
- console.log(' bonedist not run — pass --bones <correspondence.json | identity> for the stage-3 per-frame');
2739
- console.log(' bone world-transform distance. It reports and gates nothing.');
2740
- }
2741
- console.log(' Section figures are means of their own measures. There is no rung score:');
2742
- console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
2743
-
2744
- if (flags.json !== undefined) {
2745
- // No `gates` field, deliberately. The rung's gate string names its features
2746
- // and its per-skeleton counts, which `bench/runs/README.md` forbids a run
2747
- // from reading — and this report is one of the six files the run protocol
2748
- // requires committing, so a copy of it here would sit inside every future
2749
- // run's directory, which is exactly where the next author looks for process
2750
- // notes. `rung` identifies the rung and carries nothing (issue #137). The
2751
- // console block above still prints the gate string: that is for the person
2752
- // reading the run, not a file the protocol commits.
2753
- writeJson(flags.json, {
2754
- rung: rung.id,
2755
- example: rung.example,
2756
- profile,
2757
- candidate: { skeleton: skeletonPath, atlas: atlasPath },
2758
- validate: report,
2759
- // `referencePath`, not `reference`: a DiffReport already has a
2760
- // `reference` of its own (the raw counts), and the spread wins.
2761
- diffs: diffs.map((d) => ({
2762
- label: d.skeleton.label,
2763
- role: d.skeleton.role,
2764
- referencePath: d.reference,
2765
- ...d.report,
2766
- })),
2767
- check,
2768
- // Absent rather than null when the flag was not passed: `bonedist: null`
2769
- // in a stored record would read as "measured, nothing to report", and
2770
- // that is the opposite of "not measured".
2771
- ...(boneDists.length === 0
2772
- ? {}
2773
- : { boneDists: boneDists.map((b) => ({ label: b.skeleton.label, role: b.skeleton.role, ...b.report })) }),
2774
- });
2775
- }
2776
-
2777
- if (report.failures.length > 0) {
2778
- console.error(`rigc: candidate is not valid Spine — ${report.failures.length} assertion(s) failed`);
2779
- process.exit(1);
2780
- }
2781
- }
2782
-
2783
- /**
2784
- * bonedist — the ladder's stage 3, run on its own.
2785
- *
2786
- * ⚠️ It reads BOTH skeletons, so it is a finish-line instrument like `bench` and
2787
- * unlike `check`. Every convention behind every figure is printed above the
2788
- * tables, and there is no score — see [`src/bonedist.ts`](src/bonedist.ts).
2789
- */
2790
- function cmdBoneDist(flags: Record<string, string>): void {
2791
- if (flags.candidate === undefined) throw new UsageError('bonedist needs --candidate <dir | skeleton.json>');
2792
- if (flags.reference === undefined) throw new UsageError('bonedist needs --reference <skeleton.json>');
2793
- if (flags.bones === undefined) {
2794
- throw new UsageError(
2795
- `bonedist needs --bones <correspondence.json | ${IDENTITY_CORRESPONDENCE}> — a candidate is entitled to its own bone ` +
2796
- 'names, so the mapping is an input and never a guess; pass `identity` to state that the two use the same names',
2797
- );
2798
- }
2799
- // Each side's files there and its skeleton JSON, refused by name as `render` refuses them (issue #1042); a pair
2800
- // that does not load is `boneDistance`'s refusal (`CandidatePairError`, exit 2 below).
2801
- const sideOf = (target: string, atlas: string | undefined): { skeletonPath: string; atlasPath: string } => {
2802
- const side = resolveViewable({ candidate: target, ...(atlas === undefined ? {} : { atlas }) });
2803
- readSkeletonText(side.skeletonPath);
2804
- return side;
2805
- };
2806
- const candidate = sideOf(flags.candidate, flags.atlas);
2807
- const reference = sideOf(flags.reference, flags['reference-atlas']);
2808
- const report = boneDistance({
2809
- candidateSkeleton: candidate.skeletonPath,
2810
- candidateAtlas: candidate.atlasPath,
2811
- referenceSkeleton: reference.skeletonPath,
2812
- referenceAtlas: reference.atlasPath,
2813
- bones: flags.bones,
2814
- ...(flags.fps === undefined ? {} : { fps: Number(flags.fps) }),
2815
- });
2816
- console.log('rigc bonedist — per-frame bone world-transform distance (the ladder\'s stage 3)');
2817
- for (const line of boneDistLines(report, { allBones: flags['all-bones'] !== undefined })) console.log(line);
2818
- if (flags.json !== undefined) writeJson(flags.json, report);
2819
- }
2820
-
2821
- /**
2822
- * Refuse a compiled pair whose art `explain` cannot pose through, by name.
2823
- *
2824
- * 🚨 `explain` poses the rig — `deformReportLines` measures what each deform key
2825
- * did — and spine-core's reading of the pair resolves EVERY attachment against
2826
- * the atlas, whether or not anything deforms it. (Since issue #1019 a build's
2827
- * survey is read off its model document and posed by the core, which resolves
2828
- * nothing against the atlas; spine-core reads it under `--poser spine` and on a
2829
- * fallback, and this refusal is unchanged and comes before either.) On the specs
2830
- * `ingest --art none` writes there is nothing to resolve against: the entries
2831
- * state a size and name no `image`, so the compile atlases nothing, and the load
2832
- * threw the runtime's own `Region not found in atlas: rear-upper-arm (attachment:
2833
- * rear-upper-arm)` with a spine-core stack trace under it and exit 1 (measured on
2834
- * `examples/spineboy/export/spineboy-ess.json`, issue #697). That is the tool
2835
- * telling an agent about its own internals instead of about the rig, on the one
2836
- * input `docs/INGEST.md` §2.0 documents as the route through a foreign skeleton.
2837
- *
2838
- * ⭐ Both halves of the join are rigc's own readers rather than a second opinion
2839
- * on somebody else's format: the walk is `attachmentRegionJoins`, which `PS127`
2840
- * measures against the loader's own `findRegion` calls, and the region names come
2841
- * from `parseAtlasText`, which is what `--atlas-in` already resolves against.
2842
- * Names are compared EXACTLY — as `A08` compares them and as `findRegion`
2843
- * matches them — so a padded region name is a miss on both sides.
2844
- *
2845
- * ⚠️ What it deliberately does not do is catch the pose. A blanket `try` around
2846
- * `skeletonDataFromText` would convert any exception the runtime raises into a
2847
- * sentence claiming the cause is missing art, and a rigc defect reported under
2848
- * somebody else's name is the doctrine's second bullet inverted. This refuses
2849
- * the case it can NAME, before a line of the report is printed, and leaves
2850
- * anything else to arrive as itself.
2851
- */
2852
- function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
2853
- const regionNames = parseAtlasText(result.atlasText).pages.flatMap((page) => page.regions.map((region) => region.name));
2854
- const have = new Set(regionNames);
2855
- const misses: Array<{ at: string; attachment: string; lookup: string }> = [];
2856
- let lookups = 0;
2857
- for (const join of attachmentRegionJoins(JSON.parse(result.skeletonText))) {
2858
- // A `sequence` this walk will not guess at names no region it can check, and
2859
- // `A08` passes over it for the same reason.
2860
- if (join.lookups === null) continue;
2861
- for (const lookup of join.lookups) {
2862
- lookups++;
2863
- if (!have.has(lookup)) {
2864
- misses.push({
2865
- at: `skin "${join.skin}" slot "${join.slot}" placeholder "${join.placeholder}"`,
2866
- attachment: join.name,
2867
- lookup,
2868
- });
2869
- }
2870
- }
2871
- }
2872
- if (misses.length === 0) return;
2873
- const first = misses[0];
2874
- // Reported because it was measured, and absent where there is none — `A08`'s
2875
- // own near-miss clause, in `A08`'s own words.
2876
- const near = regionNames.find((region) => region.trim().toLowerCase() === first.lookup.toLowerCase());
2877
- const has =
2878
- regionNames.length === 0
2879
- ? 'it declares no region at all'
2880
- : `it declares ${regionNames.length} region(s) and none of them is that${
2881
- near === undefined ? '' : `, though it does have ${JSON.stringify(near)}`
2882
- }`;
2883
- const remedy =
2884
- opts.atlasInPath === undefined
2885
- ? 'Art reaches a compile two ways and this run took neither: `--atlas-in <pack.atlas>` resolves the parts ' +
2886
- 'against a pack somebody already made, and an "image" per attachment resolves them as loose PNGs under ' +
2887
- '`--images <dir>` — a spec that states a size and names no image is what `ingest --art none` writes, and ' +
2888
- '`--atlas-in` is what reads it'
2889
- : `Either the spec's region name or ${opts.atlasInPath} is the one that moved: fix the name, or point ` +
2890
- '`--atlas-in` at the pack that has it';
2891
- throw new ExplainError(
2892
- `${first.at}: attachment ${JSON.stringify(first.attachment)} wants region ${JSON.stringify(first.lookup)}, ` +
2893
- `which this build's atlas does not have (${has}). \`explain\` poses the rig to measure its deform keys and a ` +
2894
- `pose resolves every attachment against the atlas, so there is nothing to pose it against. ${remedy}. ` +
2895
- `${misses.length} of ${lookups} attachment lookup(s) here resolve to no region.`,
2896
- );
2897
- }
2898
-
2899
- function cmdExplain(flags: Record<string, string>): void {
2900
- // `--poser` is refused by its spelling before anything compiles (issue #1019): it chooses the deform survey's reader and poser.
2901
- const poser = readPoserFlag(flags);
2902
- const { label, opts } = resolveCut(flags);
2903
- console.log(`rigc explain ${label}`);
2904
- // See the identical pair of lines in `cmdBuild` for why both paths are named
2905
- // here rather than only the one the header's `label` happens to carry.
2906
- console.log(` .. rig ${opts.rigPath}`);
2907
- console.log(` .. motion ${opts.motionPath}`);
2908
- const result = compile(opts);
2909
- // Before a line of the report, rather than at the pose two hundred lines in:
2910
- // the blocks that need the art — DEFORM, meshes, dropped states — all sit
2911
- // BELOW the pose, so a run that printed the bone and timeline dump and then
2912
- // refused would be a report missing everything the missing art decides, with
2913
- // the sentence saying so scrolled off the top. It is the invocation that has
2914
- // to change, so it is refused before the report it cannot finish (issue #697).
2915
- refuseUnposableArt(result, opts);
2916
- // 📐 **A page whose file is not its declared size is said where the report
2917
- // starts, and nothing is refused for it** (issue #750). `explain` never
2918
- // gates, so on such a pack nothing stood between the region lift and the
2919
- // figures it fed: the mesh block printed a fit taken off another part of the
2920
- // page. The pack still compiles — a runtime draws it, and most of this report
2921
- // (bones, slots, timelines) reads no texel at all — so the page is named
2922
- // here, once, with the ratio `A06` refuses it by, and every figure that WOULD
2923
- // have been taken off its texels is withheld on its own line and says so.
2924
- // `build` prints no such line: `A06` is its statement of the same fact.
2925
- for (const grid of result.pageGrids) {
2926
- console.log(
2927
- ` .. ${grid.said}: every figure below taken off this page's texels is withheld, and says so where it ` +
2928
- `would have stood. ${PAGE_GRID_UNLOCATED}`,
2929
- );
2930
- }
2931
- // `compile` has already parsed this file, so the read below cannot fail — but
2932
- // it goes through the same parser rather than a cast, because the cast was the
2933
- // last one in the repository and issue #307 was about exactly that.
2934
- const motion = parseMotionSpec(readJsonFile(opts.motionPath), opts.motionPath);
2935
-
2936
- // A rig may state that it has no stage at all (issue #578), and the two must
2937
- // not print alike: `undefined x undefined` is what a template does with an
2938
- // absence, and it reads like a defect in the tool rather than a claim in the
2939
- // spec.
2940
- const stage =
2941
- result.skeleton.skeleton.width === undefined || result.skeleton.skeleton.height === undefined
2942
- ? 'none declared'
2943
- : `${result.skeleton.skeleton.width} x ${result.skeleton.skeleton.height}`;
2944
- console.log(`\nstage ${stage} (spine ${result.skeleton.skeleton.spine})`);
2945
-
2946
- // The crop note describes where the numbers CAME from, and without a manifest
2947
- // they came from the rig spec's own literals — there is no crop to be relative
2948
- // to. Printing it anyway told a rung-3 author their bone positions were in a
2949
- // coordinate system that did not exist in their rig.
2950
- const frame = opts.manifestPath ? ' (crop y-down -> spine y-up, origin at the bottom-left of the crop)' : ' (spine world: y up)';
2951
- console.log(`\nbones${frame}`);
2952
- for (const b of result.skeleton.bones) {
2953
- // `rotation` is the axis keystone and the grips' radial facing, so it earns
2954
- // a column even though it is absent on most bones.
2955
- const rot = b.rotation === undefined ? '' : ` rotation=${b.rotation}`;
2956
- console.log(` ${b.name.padEnd(12)} parent=${(b.parent ?? '-').padEnd(10)} x=${b.x ?? 0} y=${b.y ?? 0}${rot}`);
2957
- }
2958
-
2959
- console.log('\nslots (array order IS the draw order)');
2960
- // The DEFAULT skin's placeholders, resolved by name. It was `skins[0]` until
2961
- // issue #541, which is the same thing only because rigc pins `default` at
2962
- // index 0 — a property of the emitter that this report should not be quietly
2963
- // relying on. Reading it by name means a change to the skins ORDER cannot turn
2964
- // this line into a report about some other skin.
2965
- const defaultSkin = result.skeleton.skins.find((skin) => skin.name === 'default');
2966
- // ...and a skeleton may declare none (issue #801), in which case every
2967
- // `attachments=[]` below is true and says nothing — so the line above the
2968
- // column says where the placeholders are instead of letting it read as
2969
- // "this rig has no art".
2970
- if (defaultSkin === undefined) {
2971
- const names = result.skeleton.skins.map((skin) => JSON.stringify(skin.name));
2972
- console.log(
2973
- ` (this skeleton declares no default skin, so the attachments column lists none; its placeholders are in ` +
2974
- `${names.length === 0 ? 'no skin at all' : `the named skin(s) ${names.join(', ')}`})`,
2975
- );
2976
- }
2977
- for (const s of result.skeleton.slots) {
2978
- const atts = Object.keys(defaultSkin?.attachments[s.name] ?? {});
2979
- console.log(
2980
- ` ${s.name.padEnd(12)} bone=${s.bone.padEnd(12)} setup=${(s.attachment ?? 'null').padEnd(22)} color=${s.color ?? 'ffffffff'} attachments=[${atts.join(', ')}]`,
2981
- );
2982
- }
2983
-
2984
- console.log('\nanimations');
2985
- for (const [animName, anim] of Object.entries(result.skeleton.animations)) {
2986
- const spec = motion.animations[animName];
2987
- console.log(` ${animName} declared=${spec.duration}s loop=${spec.loop}`);
2988
- for (const [boneName, timelines] of Object.entries(anim.bones ?? {})) {
2989
- // "(mesh tier)" is a claim about what the bone DRIVES, and it was printed
2990
- // on every bone track regardless — which reads, on a rig with no mesh in
2991
- // it at all, as though the track were deforming one.
2992
- const drives = result.meshBones.includes(boneName) ? ' <- drives a mesh' : '';
2993
- for (const [timelineName, keys] of Object.entries(timelines)) {
2994
- console.log(` ${boneName}.${timelineName} ${keys.length} key(s)${drives}`);
2995
- if (timelineName === 'scale') console.log(` ${SCALE_PRODUCT_NOTE}`);
2996
- for (const emitted of keys) {
2997
- // Every channel, as the parser reads it: a channel at its parser
2998
- // default is not in the file (issue #716), and this list shows the
2999
- // value a reader has to reason about rather than the bytes.
3000
- const key = asParsed(`bone ${timelineName} key`, emitted);
3001
- const fields = Object.entries(key)
3002
- .filter(([k]) => k !== 'time' && k !== 'curve')
3003
- .map(([k, v]) => `${k}=${String(v)}`)
3004
- .join(' ');
3005
- const curve = Array.isArray(key.curve)
3006
- ? `bezier[${key.curve.length}]`
3007
- : key.curve === 'stepped'
3008
- ? 'stepped'
3009
- : 'linear';
3010
- console.log(` t=${String(key.time).padEnd(7)} ${fields.padEnd(30)} ${curve}${scaleProduct(timelineName, key)}`);
3011
- }
3012
- }
3013
- }
3014
- for (const [slotName, timelines] of Object.entries(anim.slots ?? {})) {
3015
- for (const [timelineName, keys] of Object.entries(timelines)) {
3016
- console.log(` ${slotName}.${timelineName} ${keys.length} key(s)`);
3017
- for (const emitted of keys) {
3018
- const key = asParsed(`slot ${timelineName} key`, emitted);
3019
- const curve = key.curve;
3020
- const shape = Array.isArray(curve)
3021
- ? `bezier[${curve.length}] ${curve.slice(12).join(', ')} <- alpha channel, absolute (t,v)`
3022
- : curve === 'stepped'
3023
- ? 'stepped'
3024
- : timelineName === 'attachment'
3025
- ? 'stepped (attachment timelines always are)'
3026
- : 'linear';
3027
- const value = 'color' in key ? `#${String(key.color)}` : `attachment=${String(key.name)}`;
3028
- console.log(` t=${String(key.time).padEnd(7)} ${value.padEnd(30)} ${shape}`);
3029
- }
3030
- }
3031
- }
3032
- // The two constraint groups: one unnamed timeline per constraint, so the
3033
- // name printed is the constraint's and there is no timeline name to print
3034
- // beside it. Every field a key carries is shown, because each one is
3035
- // optional in the file and the ABSENT ones are what a reader has to see —
3036
- // an omitted `softness` is 0, not "unchanged".
3037
- for (const group of ['ik', 'transform'] as const) {
3038
- for (const [name, keys] of Object.entries(anim[group] ?? {})) {
3039
- console.log(` ${group}.${name} ${keys.length} key(s) <- one timeline per constraint`);
3040
- for (const key of keys) {
3041
- const fields = Object.entries(key)
3042
- .filter(([k]) => k !== 'time' && k !== 'curve')
3043
- .map(([k, v]) => `${k}=${String(v)}`)
3044
- .join(' ');
3045
- const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
3046
- console.log(` t=${String(keyTimeOf(`${group} key`, key)).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
3047
- }
3048
- }
3049
- }
3050
- // The other two constraint groups. These DO carry a timeline name under the
3051
- // constraint (`path.<name>.position`), which is the physics shape rather
3052
- // than the ik/transform one, so the name printed is both.
3053
- for (const group of ['path', 'slider'] as const) {
3054
- for (const [name, timelines] of Object.entries(anim[group] ?? {})) {
3055
- for (const [timelineName, keys] of Object.entries(timelines)) {
3056
- console.log(` ${group}.${name}.${timelineName} ${keys.length} key(s)`);
3057
- for (const key of keys) {
3058
- const fields = Object.entries(key)
3059
- .filter(([k]) => k !== 'time' && k !== 'curve')
3060
- .map(([k, v]) => `${k}=${String(v)}`)
3061
- .join(' ');
3062
- const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
3063
- console.log(` t=${String(keyTimeOf(`${group} ${timelineName} key`, key)).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
3064
- }
3065
- }
3066
- }
3067
- }
3068
- // Deform timelines are keyed on a skin/slot/attachment triple, and the run
3069
- // is printed as its span rather than its numbers: `offset` plus a length is
3070
- // what tells a reader whether the key lands where they meant, and a hundred
3071
- // vertex offsets on one line tells them nothing.
3072
- for (const [skinName, slotMap] of Object.entries(anim.attachments ?? {})) {
3073
- for (const [slotName, attMap] of Object.entries(slotMap)) {
3074
- for (const [attName, timelines] of Object.entries(attMap)) {
3075
- for (const [timelineName, keys] of Object.entries(timelines)) {
3076
- console.log(` ${skinName}/${slotName}/${attName}.${timelineName} ${keys.length} key(s)`);
3077
- for (const emitted of keys) {
3078
- const key = asParsed(`attachment ${timelineName} key`, emitted);
3079
- const run = Array.isArray(key.vertices) ? (key.vertices as number[]) : null;
3080
- const offset = typeof key.offset === 'number' ? key.offset : 0;
3081
- const span = run
3082
- ? `deform[${offset}..${offset + run.length}] ${run.length / 2} pair(s)`
3083
- : 'back to the setup pose';
3084
- const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
3085
- console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
3086
- // A generated key prints its MODEL and then every offset the model
3087
- // produced (issue #294). Both halves are the point: the model is
3088
- // what a reviewer checks a claim against, and the offsets are what
3089
- // reaches the file — printing only the first would ask a reader to
3090
- // trust an evaluation they cannot see, which is the gap FACE §9.3
3091
- // records. The numbers are the emitted ones, not a second
3092
- // evaluation, so this block and the artifact cannot disagree.
3093
- const gen = result.deformTransforms.find(
3094
- (g) => g.animation === animName && g.skin === skinName && g.slot === slotName && g.attachment === attName && g.time === key.time,
3095
- );
3096
- if (gen === undefined) continue;
3097
- console.log(` transform ${gen.kind} ${gen.stated}`);
3098
- console.log(` ${gen.formula}`);
3099
- for (const line of gen.derived) console.log(` ${line}`);
3100
- console.log(
3101
- ` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
3102
- );
3103
- for (let v = 0; v < gen.vertexCount; v += 4) {
3104
- const pairs: string[] = [];
3105
- for (let k = v; k < Math.min(v + 4, gen.vertexCount); k++) {
3106
- pairs.push(`v${String(k).padStart(3)} (${gen.offsets[2 * k]}, ${gen.offsets[2 * k + 1]})`);
3107
- }
3108
- console.log(` ${pairs.join(' ')}`);
3109
- }
3110
- // On a multi-influence attachment those pairs are the model's
3111
- // WORLD displacements, and the file holds one `Mᵢ⁻¹·D` pair per
3112
- // influence instead (issue #389). Printing the first without the
3113
- // second would put numbers on the screen that are nowhere in the
3114
- // artifact — the exact gap this block exists to close.
3115
- if (gen.expanded !== undefined) {
3116
- console.log(
3117
- ` written as ${gen.expanded.length / 2} per-influence pair(s), each vertex's D through ` +
3118
- 'its own bone inverse',
3119
- );
3120
- for (let i = 0; i < gen.expanded.length / 2; i += 4) {
3121
- const pairs: string[] = [];
3122
- for (let k = i; k < Math.min(i + 4, gen.expanded.length / 2); k++) {
3123
- pairs.push(`i${String(k).padStart(3)} (${gen.expanded[2 * k]}, ${gen.expanded[2 * k + 1]})`);
3124
- }
3125
- console.log(` ${pairs.join(' ')}`);
3126
- }
3127
- }
3128
- }
3129
- }
3130
- }
3131
- }
3132
- }
3133
- // The draw-order timeline names no target, so it hangs off the animation
3134
- // rather than off a slot — and a timeline `explain` did not print would be a
3135
- // timeline nobody could check without reading the emitted JSON.
3136
- if (anim.drawOrder) {
3137
- console.log(` drawOrder ${anim.drawOrder.length} key(s) <- whole animation, offsets against the SETUP order`);
3138
- for (const key of anim.drawOrder) {
3139
- const offsets = Array.isArray(key.offsets)
3140
- ? (key.offsets as Array<{ slot: string; offset: number }>)
3141
- .map((o) => `${o.slot}${o.offset >= 0 ? '+' : ''}${o.offset}`)
3142
- .join(' ')
3143
- : 'back to the setup order';
3144
- console.log(` t=${String(keyTimeOf('drawOrder key', key)).padEnd(7)} ${offsets}`);
3145
- }
3146
- }
3147
- }
3148
-
3149
- // The `MEMBER` block sits beside the `DEFORM` one and for the same reason:
3150
- // both re-print timelines the reader has just read, in the arrangement the
3151
- // question needs rather than the one the format has.
3152
- for (const line of memberReportLines(result)) console.log(line);
3153
-
3154
- // The `DEFORM` block goes after the timelines and before the constraints,
3155
- // because it is a measurement OF the deform timelines printed above — the keys
3156
- // it names are the keys the reader has just read, by the same index.
3157
- for (const line of deformReportLines(result, new Set(result.rig.deformMayFold), poser, label)) console.log(line);
3158
-
3159
- if (result.physics.length) {
3160
- console.log('\nphysics constraints (4.3 top-level `constraints` array, type per entry)');
3161
- for (const ph of result.physics) {
3162
- console.log(` ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] mix=${ph.mix} drivesMesh=${ph.drivesMesh}`);
3163
- }
3164
- }
3165
-
3166
- // Path constraints, with the curve each one follows MEASURED — its length and
3167
- // its curve count are the two numbers an author cannot get from the spec, and
3168
- // `position` means nothing without the first of them under `positionMode:
3169
- // "percent"`. Read off the emitted attachment rather than recomputed here.
3170
- const constraintsOf = (type: string) => (result.skeleton.constraints ?? []).filter((c) => c.type === type);
3171
- const pathConstraints = constraintsOf('path');
3172
- if (pathConstraints.length) {
3173
- console.log('\npath constraints (position is a fraction of the measured length under positionMode "percent")');
3174
- for (const c of pathConstraints) {
3175
- const slot = String(c.slot);
3176
- const attachments = result.skeleton.skins.flatMap((skin) => Object.values(skin.attachments[slot] ?? {}));
3177
- const curve = attachments.find((att) => (att as { type?: string }).type === 'path') as
3178
- | { lengths?: number[]; closed?: boolean; constantSpeed?: boolean }
3179
- | undefined;
3180
- // `vertexCount / 3` entries on both shapes since issue #804, so an open
3181
- // path's curves are one fewer than its entries and its length is the last
3182
- // CURVE's entry — the trailing one is the wrap-around curve nothing reads.
3183
- const lengths = (curve?.lengths ?? []).slice(0, curve?.closed ? undefined : -1);
3184
- console.log(
3185
- ` ${c.name.padEnd(12)} slot=${slot.padEnd(12)} bones=[${(c.bones as string[]).join(', ')}] ` +
3186
- `position=${c.position ?? 0} ${String(c.positionMode ?? 'percent')}/${String(c.spacingMode ?? 'length')}/${String(c.rotateMode ?? 'tangent')}`,
3187
- );
3188
- console.log(
3189
- ` ${''.padEnd(12)} curve: ${lengths.length} curve(s), ${lengths[lengths.length - 1] ?? 0} long, ` +
3190
- `${curve?.closed ? 'closed' : 'open'}, constantSpeed=${curve?.constantSpeed ?? true}`,
3191
- );
3192
- }
3193
- }
3194
-
3195
- const sliders = constraintsOf('slider');
3196
- if (sliders.length) {
3197
- console.log('\nsliders (each applies one animation at a time it chooses)');
3198
- for (const c of sliders) {
3199
- const driver = c.bone === undefined ? `time=${c.time ?? 0} (keyed by slider.${c.name}.time)` : `bone=${String(c.bone)}.${String(c.property)}`;
3200
- console.log(
3201
- ` ${c.name.padEnd(12)} applies=${String(c.animation).padEnd(14)} ${driver} ` +
3202
- `mix=${c.mix ?? 1} loop=${c.loop ?? false} additive=${c.additive ?? false}`,
3203
- );
3204
- }
3205
- }
3206
-
3207
- // Which bones and constraints a skin switches on. Printed because the pairing
3208
- // with `skin: true` is invisible in the emitted file: a member list and a
3209
- // skinRequired flag are two keys in two places, and only together do they mean
3210
- // "this bone belongs to this skin".
3211
- 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);
3212
- if (skinMembers.length) {
3213
- console.log('\nskin members (skinRequired bones and constraints, active only under their own skin)');
3214
- for (const skin of skinMembers) {
3215
- const lists = (['bones', 'ik', 'transform', 'path', 'physics', 'slider'] as const)
3216
- .filter((key) => skin[key]?.length)
3217
- .map((key) => `${key}=[${skin[key]!.join(', ')}]`)
3218
- .join(' ');
3219
- console.log(` ${skin.name.padEnd(12)} ${lists}`);
3220
- }
3221
- }
3222
-
3223
- if (result.meshes.length) {
3224
- console.log('\nmeshes');
3225
- for (const kind of new Set(result.meshes.map((m) => m.kind))) console.log(` ${MESH_KIND_NOTES[kind]}`);
3226
- for (const m of result.meshes) {
3227
- // The depth block belongs here more than it belongs in `build`: `explain`
3228
- // is the command that says what a spec MEANS, and the turn ceiling is the
3229
- // number an author needs before writing a key rather than after a refusal.
3230
- // It was absent, while `docs/AUTHORING.md` said both commands printed it.
3231
- console.log(
3232
- ` ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
3233
- `${meshBudget(result.rig)} bones=[${m.bones.join(', ')}]${meshFit(m)}` +
3234
- meshDepthNote(m) +
3235
- meshInfluenceNote(m),
3236
- );
3237
- }
3238
- }
3239
-
3240
- if (result.droppedStates.length) {
3241
- // The heading said "no PNG on disk" and the line printed the path, on a
3242
- // command that takes `--atlas-in` like `build` does — so an explain of a
3243
- // pack build named a file it never opened. Same renderer as the other two
3244
- // printers now, for the same reason they share one (issue #671).
3245
- console.log('\ndropped states (listed in the manifest, no art behind them)');
3246
- for (const d of result.droppedStates) console.log(` ${d.slot}/${d.state} ${droppedStateReason(d)}`);
3247
- }
3248
-
3249
- console.log('\nmix table (player config, not skeleton JSON)');
3250
- console.log(` default=${motion.mix?.default ?? 0} pairs=${JSON.stringify(motion.mix?.pairs ?? [])}`);
3251
- }
3252
-
3253
- /**
3254
- * ingest — a skeleton back into the two specs that rebuild it.
3255
- *
3256
- * The only command that runs against `build`'s direction, and the contract is an
3257
- * equality rather than a rulebook: `build(ingest(A))` is `A`. Everything it
3258
- * cannot carry is a **finding** printed here with its code, because those lines
3259
- * are what tells an author what the rebuilt rig will not have — they are this
3260
- * command's whole UI, exactly as the validator's messages are `build`'s.
3261
- *
3262
- * ⚠️ It writes both specs even when a blocker was found, and then exits
3263
- * non-zero: a blocker means the rebuild will not be the file that was read, and
3264
- * the useful thing at that point is the spec plus the list of what is missing
3265
- * from it. `build`'s "nothing written" rule is not this rule — that one is about
3266
- * a **gated artifact** on disk, and these two files are inputs to the gate
3267
- * rather than output of it.
3268
- */
3269
- function cmdIngest(flags: Record<string, string>, positional: string[]): void {
3270
- const [source] = positional;
3271
- if (!source) throw new UsageError('ingest takes one path: <skeleton.json>');
3272
- const skeletonPath = resolve(source);
3273
- if (!existsSync(skeletonPath)) throw new UsageError(`nothing at ${skeletonPath}`);
3274
- // The same sentence `resolveArtifacts` refuses a `.spine` with, for the same
3275
- // reason (docs/INGEST.md §5): this reads Spine 4.3 skeleton JSON and nothing
3276
- // else — not a project file, not a binary `.skel`, not the atlas.
3277
- if (!skeletonPath.endsWith('.json')) {
3278
- throw new UsageError(
3279
- `${skeletonPath} is not a .json skeleton — ingest reads Spine 4.3 skeleton JSON and nothing else: not a ` +
3280
- '.spine project, not a binary .skel, not an atlas. Re-export as JSON',
3281
- );
3282
- }
3283
- if (flags.out === undefined) throw new UsageError('ingest needs --out <dir> — the directory to write rig.json and motion.json into');
3284
- const art = flags.art ?? 'loose';
3285
- if (art !== 'loose' && art !== 'none') {
3286
- throw new UsageError(
3287
- `--art is ${JSON.stringify(art)}; it is "loose" (name an image per attachment, for \`build --images <dir>\`) ` +
3288
- 'or "none" (state width/height only, for `build --atlas-in <pack>`). A skeleton encodes neither, which is ' +
3289
- 'why this is a flag',
3290
- );
3291
- }
3292
- let stage: IngestStage | undefined;
3293
- if (flags.stage !== undefined) {
3294
- const parts = flags.stage.split(',').map(Number);
3295
- if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
3296
- throw new UsageError(`--stage is ${JSON.stringify(flags.stage)}; give four numbers, x,y,width,height`);
3297
- }
3298
- stage = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
3299
- }
3300
- const outDir = resolve(flags.out);
3301
- /**
3302
- * The rig spec's own `images`, spelled from `--out` the way `build` spells
3303
- * `skeleton.images` from its own output directory — the SAME function, so the
3304
- * two conventions cannot drift (issue #595). Without it the field is left out,
3305
- * an `image` name resolves against the spec's own directory, and every rebuild
3306
- * of the spec has to carry `build --images <dir>`.
3307
- */
3308
- let specImages: string | undefined;
3309
- if (flags.images !== undefined) {
3310
- // Refused rather than ignored, for the reason `chainfit` refuses `--atlas`:
3311
- // a flag that silently does nothing is worse than one that says why it
3312
- // cannot. Measured — an `--art none` spec rebuilt with `--images` naming a
3313
- // directory that does not exist is byte-identical to one rebuilt without
3314
- // it, because no attachment carries an `image` for that directory to be the
3315
- // base of.
3316
- if (art === 'none') {
3317
- throw new UsageError(
3318
- '--images <dir> and --art none contradict: `none` writes width/height and no `image` at all, so the rig ' +
3319
- "spec's `images` directory would be the base of nothing and no rebuild would read it. Use --art loose to " +
3320
- 'name an image per attachment, or drop --images — an `--art none` spec rebuilds with `build --atlas-in ' +
3321
- '<pack.atlas>`',
3322
- );
3323
- }
3324
- // Not checked for existence, deliberately: `ingest` reads the skeleton and
3325
- // nothing else, so the parts may well be extracted AFTER the specs are
3326
- // written, and refusing a directory this command never opens would refuse a
3327
- // legitimate order of work. `build` is where a missing PNG is named.
3328
- specImages = relativeImagesPath(outDir, resolve(flags.images));
3329
- }
3330
- console.log(`rigc ingest ${skeletonPath}`);
3331
- console.log(` .. out ${outDir}`);
3332
- console.log(` .. art ${art}`);
3333
- if (specImages !== undefined) console.log(` .. images ${specImages} (the rig spec's own, from ${outDir})`);
3334
-
3335
- /**
3336
- * What the run has to report, however the parse went.
3337
- *
3338
- * 🔒 A spec the tree's own parser refuses is a **finding**, not an escape
3339
- * (issue #692): the three files are written, the coded `BLOCK` line is
3340
- * printed, and the exit code comes off the findings like every other run's.
3341
- * The two specs are read as `unknown` because that is all this function does
3342
- * with them — `JSON.stringify` — and a cast to `RigSpec` here would be this
3343
- * file claiming a parse that did not happen.
3344
- */
3345
- let result: { rig: unknown; motion: unknown; findings: IngestFinding[] };
3346
- try {
3347
- result = ingest(readJsonFile(skeletonPath), {
3348
- name: flags.name ?? basename(skeletonPath, '.json'),
3349
- art,
3350
- images: specImages,
3351
- stage,
3352
- source: basename(skeletonPath),
3353
- version: readVersion(),
3354
- });
3355
- } catch (err) {
3356
- if (!(err instanceof IngestSpecRefused)) throw err;
3357
- result = { rig: err.rig, motion: err.motion, findings: err.findings };
3358
- }
3359
-
3360
- mkdirSync(outDir, { recursive: true });
3361
- // Indent 2, which is what `compile` writes the skeleton with. One emitter
3362
- // convention, so a spec and the skeleton it came from read the same way.
3363
- writeFileSync(join(outDir, 'rig.json'), `${JSON.stringify(result.rig, null, 2)}\n`);
3364
- writeFileSync(join(outDir, 'motion.json'), `${JSON.stringify(result.motion, null, 2)}\n`);
3365
- writeFileSync(join(outDir, 'findings.json'), `${JSON.stringify(result.findings, null, 2)}\n`);
3366
-
3367
- // Grouped by kind rather than printed in discovery order: a blocker is what
3368
- // decides the exit code, and a reader scanning for one should not have to
3369
- // read past a hundred DURATION lines to find it.
3370
- // The gutter words are `INGEST_GUTTERS`, which is also the column
3371
- // `docs/INGEST.md` §2.0's finding-code table is keyed on (issue #675); the
3372
- // pad to one width is this printer's, so the codes line up.
3373
- const width = Math.max(...Object.values(INGEST_GUTTERS).map((gutter) => gutter.length));
3374
- for (const kind of ['blocker', 'judgement', 'lossy'] as const) {
3375
- for (const finding of result.findings.filter((f) => f.kind === kind)) {
3376
- console.log(` ${INGEST_GUTTERS[kind].padEnd(width)} ${finding.code}: ${finding.where} — ${finding.detail}`);
3377
- }
3378
- }
3379
- console.log(`rigc: wrote ${join(outDir, 'rig.json')}`);
3380
- console.log(`rigc: wrote ${join(outDir, 'motion.json')}`);
3381
- console.log(`rigc: wrote ${join(outDir, 'findings.json')}`);
3382
- // The hint is the command the caller will actually run, so it drops `--images`
3383
- // exactly when the spec now carries the directory itself — a hint that asks for
3384
- // a flag the spec made unnecessary is the defect issue #595 is about, printed.
3385
- const artFlag = art === 'none' ? ' --atlas-in <pack.atlas>' : specImages === undefined ? ' --images <dir>' : '';
3386
- console.log(
3387
- `rigc: build it with rigc build --rig ${join(outDir, 'rig.json')} --motion ${join(outDir, 'motion.json')}` +
3388
- `${artFlag} --out <dir>`,
3389
- );
3390
-
3391
- const blockers = result.findings.filter((f) => f.kind === 'blocker');
3392
- if (blockers.length > 0) {
3393
- console.error(
3394
- `rigc: ${blockers.length} blocker(s) — both specs were written, and a build from them will NOT be the ` +
3395
- `skeleton that was read (${[...new Set(blockers.map((f) => f.code))].join(', ')})`,
3396
- );
3397
- process.exit(1);
3398
- }
3399
- }
3400
-
3401
- // ---------------------------------------------------------------------------
3402
- // skills install — put the shipped skills where an agent host looks (issue #831)
3403
- // ---------------------------------------------------------------------------
3404
- //
3405
- // After `bun add -d spine-rigc` the skills sit at `node_modules/spine-rigc/skills/`,
3406
- // which no host reads. Codex, Gemini CLI and Antigravity all read
3407
- // `<workspace>/.agents/skills/<name>/`, so this links every `skills/<name>/` the
3408
- // package ships into one directory — `.agents/skills` under the working
3409
- // directory unless `--dir` says otherwise.
3410
- //
3411
- // ⭐ The skills are found from THIS FILE's location, never from the working
3412
- // directory: the command installs the package it is, and a cwd that happens to
3413
- // hold some other `skills/` is not a source. That is also why it lives here and
3414
- // not in `src/`: its one input is where the CLI was installed, nothing else
3415
- // calls it, and `src/` is about rigs.
3416
- //
3417
- // A RELATIVE symlink by default, so the directory survives the project being
3418
- // moved or cloned elsewhere and an upgrade of the package is seen with no second
3419
- // run. `--copy` writes the folder instead, for a host that does not follow a
3420
- // linked skill folder.
3421
- //
3422
- // 🔒 **An entry that is already there and is not what this command would write is
3423
- // refused by name, and then nothing at all is written.** The check runs over
3424
- // every skill before the first write, so a refusal never leaves half an install
3425
- // behind. The one entry that is NOT refused is the one this command would have
3426
- // made — a link that already resolves to the same skill folder, however it is
3427
- // spelled, or with `--copy` a folder whose files are byte for byte the package's
3428
- // — and a run over only those says it had nothing to do. No lifecycle script
3429
- // does this on install: a postinstall writing into a consumer's project root is
3430
- // refused as design, and Bun does not run a dependency's lifecycle scripts
3431
- // outside `trustedDependencies`, so half the installs would silently skip it.
3432
- // ---------------------------------------------------------------------------
3433
-
3434
- /** The default `--dir`, resolved against the caller's working directory. */
3435
- const DEFAULT_SKILLS_DIR = '.agents/skills';
3436
-
3437
- /** What `rigc skills` offers. One today; the list is what the refusal of any other word prints. */
3438
- const SKILLS_SUBCOMMANDS = ['install'];
3439
-
3440
- /** An install refused before its first write — nothing to install, or an entry in the way. Exit 1. */
3441
- class SkillsInstallError extends Error {}
3442
-
3443
- type SkillsInstallAction = 'linked' | 'copied' | 'already linked' | 'already copied';
3444
-
3445
- interface SkillsInstallEntry {
3446
- /** `<dir>/<name>`. */
3447
- target: string;
3448
- /** `<package>/skills/<name>`. */
3449
- source: string;
3450
- action: SkillsInstallAction;
3451
- /** The link text, relative to the directory it sits in, when the entry is a link. */
3452
- link: string;
3453
- }
3454
-
3455
- /** Every `<name>/` under `source` that holds a `SKILL.md`, in name order, so two runs print the same lines. */
3456
- function shippedSkills(source: string): string[] {
3457
- if (!existsSync(source) || !statSync(source).isDirectory()) return [];
3458
- return readdirSync(source)
3459
- .filter((name) => statSync(join(source, name)).isDirectory() && existsSync(join(source, name, 'SKILL.md')))
3460
- .sort();
3461
- }
3462
-
3463
- /**
3464
- * The real path of `path` whether or not it exists yet: the real path of its
3465
- * nearest existing ancestor with the rest appended. A relative link has to be
3466
- * computed between two paths spelled the same way, and on macOS the temp
3467
- * directory alone is reached as `/var/…` and is really `/private/var/…`.
3468
- */
3469
- function realpathAhead(path: string): string {
3470
- const rest: string[] = [];
3471
- let at = resolve(path);
3472
- while (!existsSync(at)) {
3473
- const up = dirname(at);
3474
- if (up === at) break;
3475
- rest.unshift(basename(at));
3476
- at = up;
3477
- }
3478
- return join(realpathSync(at), ...rest);
3479
- }
3480
-
3481
- /** Every file under `root`, relative and sorted, so two trees compare in one order. */
3482
- function filesUnder(root: string, prefix = ''): string[] {
3483
- const out: string[] = [];
3484
- for (const name of readdirSync(join(root, prefix)).sort()) {
3485
- const rel = prefix === '' ? name : `${prefix}/${name}`;
3486
- if (lstatSync(join(root, rel)).isDirectory()) out.push(...filesUnder(root, rel));
3487
- else out.push(rel);
3488
- }
3489
- return out;
3490
- }
3491
-
3492
- /** The first way `copy` differs from `original`, or null when every file is the same bytes. */
3493
- function firstDifference(copy: string, original: string): string | null {
3494
- const theirs = filesUnder(copy);
3495
- const ours = filesUnder(original);
3496
- for (const rel of ours) {
3497
- if (!theirs.includes(rel)) return `${rel} is missing from it`;
3498
- if (!readFileSync(join(copy, rel)).equals(readFileSync(join(original, rel)))) return `${rel} differs`;
3499
- }
3500
- for (const rel of theirs) if (!ours.includes(rel)) return `${rel} is in it and not in the package`;
3501
- return null;
3502
- }
3503
-
3504
- /**
3505
- * Install every shipped skill into `dir`, or refuse and write nothing.
3506
- *
3507
- * The plan is made in full before the first write: every entry is classified as
3508
- * absent, already this command's, or in the way, and one entry in the way
3509
- * refuses the whole call with every such entry named.
3510
- */
3511
- function installSkills(source: string, dir: string, copy: boolean): SkillsInstallEntry[] {
3512
- const names = shippedSkills(source);
3513
- if (names.length === 0) {
3514
- throw new SkillsInstallError(
3515
- `no skill to install: ${source} ${existsSync(source) ? 'holds no <name>/SKILL.md' : 'is not there'}, and it is ` +
3516
- 'the skills/ directory of the package this command ran from; nothing was written',
3517
- );
3518
- }
3519
- if (existsSync(dir) && !statSync(dir).isDirectory()) {
3520
- throw new SkillsInstallError(`${dir} exists and is not a directory, so no skill can be installed into it; nothing was written`);
3521
- }
3522
- const realDir = realpathAhead(dir);
3523
- const planned: SkillsInstallEntry[] = [];
3524
- const refused: string[] = [];
3525
- for (const name of names) {
3526
- const target = join(dir, name);
3527
- const from = join(source, name);
3528
- const realFrom = realpathSync(from);
3529
- const link = relative(realDir, realFrom);
3530
- let action: SkillsInstallAction = copy ? 'copied' : 'linked';
3531
- let found: string | null = null;
3532
- const stat = existsSync(target) || isLink(target) ? lstatSync(target) : null;
3533
- if (stat === null) {
3534
- // absent: this command writes it
3535
- } else if (stat.isSymbolicLink()) {
3536
- const text = readlinkSync(target);
3537
- const pointsAt = resolve(realDir, text);
3538
- const lands = existsSync(pointsAt) ? realpathSync(pointsAt) : null;
3539
- if (lands === realFrom && !copy) action = 'already linked';
3540
- else if (lands === realFrom) found = `a symlink to ${text}, the package's own folder, and --copy asks for a directory in its place`;
3541
- else found = `a symlink to ${text}, ${lands === null ? 'which resolves to nothing' : `which resolves to ${lands}`}`;
3542
- } else if (stat.isDirectory()) {
3543
- const difference = copy ? firstDifference(target, from) : null;
3544
- if (!copy) found = 'a directory';
3545
- else if (difference === null) action = 'already copied';
3546
- else found = `a directory that is not the package's copy (${difference})`;
3547
- } else {
3548
- found = 'a plain file';
3549
- }
3550
- if (found !== null) refused.push(`${target} is ${found}; ${copy ? `a copy of ${from}` : `a symlink to ${link}`} was required`);
3551
- else planned.push({ target, source: from, action, link });
3552
- }
3553
- if (refused.length > 0) {
3554
- throw new SkillsInstallError(
3555
- `${refused.length} of the ${names.length} skill(s) cannot be installed into ${dir}, and nothing was written:\n` +
3556
- refused.map((line) => ` ${line}`).join('\n') +
3557
- '\nRemove the entries named above, or pass --dir to install somewhere else.',
3558
- );
3559
- }
3560
- mkdirSync(dir, { recursive: true });
3561
- for (const entry of planned) {
3562
- if (entry.action === 'linked') symlinkSync(entry.link, entry.target, 'dir');
3563
- else if (entry.action === 'copied') cpSync(entry.source, entry.target, { recursive: true, errorOnExist: true, force: false });
3564
- }
3565
- return planned;
3566
- }
3567
-
3568
- /** A dangling link is not `existsSync`, and is still an entry in the way. */
3569
- function isLink(path: string): boolean {
3570
- try {
3571
- return lstatSync(path).isSymbolicLink();
3572
- } catch {
3573
- return false;
3574
- }
3575
- }
3576
-
3577
- function cmdSkills(flags: Record<string, string>, positional: string[]): void {
3578
- const [sub, ...extra] = positional;
3579
- if (sub === undefined) {
3580
- throw new UsageError(
3581
- `skills takes a subcommand: ${SKILLS_SUBCOMMANDS.join(', ')} — \`rigc skills install\` links every skill this ` +
3582
- `package ships into ${DEFAULT_SKILLS_DIR}`,
3583
- );
3584
- }
3585
- if (!SKILLS_SUBCOMMANDS.includes(sub)) {
3586
- throw new UsageError(`unknown skills subcommand: ${sub} (rigc skills offers ${SKILLS_SUBCOMMANDS.join(', ')})`);
3587
- }
3588
- if (extra.length > 0) {
3589
- throw new UsageError(
3590
- `skills install takes no positional argument, and ${JSON.stringify(extra[0])} was given — the directory is --dir <path>`,
3591
- );
3592
- }
3593
- const takes = COMMANDS.find((c) => c.name === 'skills')?.flags ?? [];
3594
- const foreign = Object.keys(flags).filter((flag) => !takes.includes(flag));
3595
- if (foreign.length > 0) {
3596
- throw new UsageError(
3597
- `skills install takes ${takes.map((flag) => `--${flag}`).join(' and ')}; ` +
3598
- `${foreign.map((flag) => `--${flag}`).join(', ')} is not one of them`,
3599
- );
3600
- }
3601
- const copy = flags.copy !== undefined;
3602
- const dir = resolve(process.cwd(), flags.dir ?? DEFAULT_SKILLS_DIR);
3603
- const entries = installSkills(join(import.meta.dir, 'skills'), dir, copy);
3604
- for (const entry of entries) {
3605
- const ends = entry.action.endsWith('linked') ? `${entry.target} -> ${entry.link} (${entry.source})` : `${entry.target} <- ${entry.source}`;
3606
- console.log(` ${entry.action.padEnd(14)} ${ends}`);
3607
- }
3608
- const wrote = entries.filter((entry) => entry.action === 'linked' || entry.action === 'copied').length;
3609
- const verb = copy ? 'copied' : 'linked';
3610
- console.log(
3611
- wrote === 0
3612
- ? `rigc skills install: nothing to do — all ${entries.length} skill(s) are already ${verb} into ${dir}`
3613
- : `rigc skills install: ${wrote} of ${entries.length} skill(s) ${verb} into ${dir}` +
3614
- (wrote < entries.length ? `, ${entries.length - wrote} already there` : ''),
3615
- );
3616
- }
3617
-
3618
- // ---------------------------------------------------------------------------
3619
- // usage / per-command help
3620
- // ---------------------------------------------------------------------------
3621
-
3622
- /**
3623
- * One meaning per flag name, shared by every command that takes it — the
3624
- * single place this project states what a flag means. AUTHORING.md §0 quotes
3625
- * this table for `build`'s `--rig`/`--motion`/`--out`/`--images`/`--manifest`/
3626
- * `--profile`; if the two ever disagree, this is the one the code runs.
3627
- */
3628
- const FLAG_MEANINGS: Record<string, string> = {
3629
- rig: 'the rig spec — skeleton structure',
3630
- motion: 'the motion spec — time',
3631
- out: 'directory for skeleton.json + skeleton.atlas (and, from build, skeleton.model.json); atlas page paths and skeleton.images are written relative to it',
3632
- images: "override the rig spec's own images directory (relative to your working directory)",
3633
- manifest: 'a cut manifest, for a rig with measured art behind it; a foreign skeleton has none',
3634
- 'copy-images':
3635
- 'also copy every referenced page PNG into --out and rewrite the atlas to the copies, so the directory is ' +
3636
- 'self-contained enough to zip or commit on its own, and point skeleton.images at --out itself so the editor finds ' +
3637
- 'the parts beside the skeleton on import (default: page paths still point at the source art)',
3638
- pack: 'arrange every part PNG onto shared atlas page(s) written into --out as real PNGs, instead of one page ' +
3639
- 'per part. Lossless: every region is a byte-for-byte copy and nothing is resampled, trimmed or rotated ' +
3640
- '(default: one part, one page, pointing at the source art)',
3641
- 'page-size': `largest page edge, --pack only (default ${DEFAULT_PAGE_SIZE}); pages are powers of two and the ` +
3642
- 'one written is the smallest that holds the pack, spilling to more pages only when the set will not fit',
3643
- padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
3644
- "extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
3645
- 'page-edges': `what a page's edges may be, --pack only (default ${DEFAULT_PAGE_EDGES}): pot is a power of two on ` +
3646
- 'both; free tries every width from the widest part up, takes the height the placement needs and keeps ' +
3647
- 'the least area — a smaller page, at the cost of region attachments sampling within 1 LSB of the loose ' +
3648
- 'build rather than exactly',
3649
- 'atlas-in':
3650
- 'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
3651
- 'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
3652
- cut: 'look up a named cut in --cuts <cuts.json>, instead of --rig/--motion/--out',
3653
- cuts: 'the cuts.json --cut names',
3654
- profile:
3655
- 'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
3656
- "correctly; spine-html = also this project's renderer/archetype policy",
3657
- atlas: "the candidate's atlas, when it is not beside the skeleton",
3658
- reference: 'the reference skeleton to pose beside the candidate — a directory or a skeleton.json path',
3659
- 'reference-atlas': "the reference's atlas, when it is not beside the reference skeleton",
3660
- bones: `a bone correspondence — { "spec": "${BONEDIST_SPEC}", "bones": { "<candidate bone>": "<reference bone>" }, ` +
3661
- '"animations"?: { … } } — or `identity` to state that the two skeletons use the same names. An INPUT, never ' +
3662
- 'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
3663
- 'as a measurement',
3664
- 'all-bones': 'print every bone pair, not just the worst by position',
3665
- geometry:
3666
- `also write ${GEOMETRY_FILE} into each frame directory: per frame, every bone's world transform and every ` +
3667
- "slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry — on the " +
3668
- 'frames\' own grid and viewport. Not with --slot/--hide: the geometry is the whole pose whatever is drawn',
3669
- poser:
3670
- "`render` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
3671
- 'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
3672
- "beside the skeleton and the skeleton is the one the document records (spine.sha256), `spine` otherwise and wherever the core refuses the input by name; the `poser` " +
3673
- 'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name. ' +
3674
- "`explain`: which reads and poses the DEFORM block's survey — `core` (the model document the compile writes, spine-core untouched) or " +
3675
- '`spine` (the Spine skeleton parsed and posed by spine-core); default `core`, and `spine` where the core refuses the document, which the block names',
3676
- 'texture-from':
3677
- "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
3678
- 'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
3679
- 'through. ⚠️ NOT --atlas: that one names the candidate\'s own atlas and loading a foreign one there re-seats ' +
3680
- 'every region attachment on its packing, so a rotated or trimmed pack moves the geometry too',
3681
- candidate: 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path',
3682
- frames: 'a rendered reference frame set (a skeleton root, or one animation directory)',
3683
- fps: 'frame rate, only for a frame set with no frames.json sidecar',
3684
- viewport: "pin the candidate's world box, y up, instead of fitting it",
3685
- framing: 'fit each frame set on its own (default) or once across all of them',
3686
- as: 'the candidate animation to play, when it is named differently from the frame set',
3687
- 'all-frames': 'print every frame, not just the worst by MAE',
3688
- json: 'also write the whole report to this path',
3689
- frame: 'one pose frame — a picture of the pose to read the part placements out of',
3690
- scale: `the scale window to search, as frame pixels per part pixel (default \`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`)`,
3691
- rotation: 'the rotation window to search, in screen degrees (default `-180,180`, a full turn)',
3692
- 'max-residual':
3693
- `above this residual a placement is refused by name instead of reported flat (default ${DEFAULT_MAX_RESIDUAL}); ` +
3694
- 'it is a reporting threshold, not a pass bar',
3695
- anchor:
3696
- 'a `rigc pose` report for THIS frame, whose confident placements become the anchors the chains hang off ' +
3697
- '(default: run that pass internally over exactly the parts the candidate draws)',
3698
- hinge:
3699
- `the window each child bone's local rotation is searched over, in Spine degrees about its setup value ` +
3700
- `(default \`${DEFAULT_HINGE_MIN},${DEFAULT_HINGE_MAX}\`, a full turn — one degree of freedom is cheap enough not ` +
3701
- 'to risk a window that does not contain the truth)',
3702
- stretch:
3703
- 'also search a uniform scale on every bone, over this ratio either way (e.g. 1.25). Without it the stretch ' +
3704
- "degree of freedom is searched only where the candidate's own animations key a `scale` timeline, because a rig " +
3705
- 'that never scales a bone is a rig saying that bone does not stretch',
3706
- 'min-visible':
3707
- `below this share of a part surviving the parts drawn over it, the placement is refused by name instead of ` +
3708
- `reported flat (default ${DEFAULT_MIN_VISIBLE}); the best one found is still printed, and it is a reporting ` +
3709
- 'threshold, not a pass bar',
3710
- passes:
3711
- `how many times the occluder masks are rebuilt from the answers and the fit rerun (default ${DEFAULT_PASSES}); ` +
3712
- "pass 1 freezes each part's visible set where the RIG predicts it, later passes where the last one landed",
3713
- 'anchor-residual':
3714
- `the residual a \`pose\` placement must be within to anchor a chain (default ${ANCHOR_MAX_RESIDUAL}, with ` +
3715
- `unexplained ≤ ${ANCHOR_MAX_UNEXPLAINED} and unambiguous — the 2026-09-03 measurement run's own clean-frame criterion)`,
3716
- 'inward-lever':
3717
- `how far apart, in frame pixels, two anchored descendants have to sit before the rotation they determine is ` +
3718
- `printed (default ${DEFAULT_MIN_LEVER_PX}); below it the bone is refused \`no-bracket\` naming the measured ` +
3719
- 'lever, because an angle read across a short lever turns a half-pixel anchor error into several degrees',
3720
- animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
3721
- skin:
3722
- 'pose under this skin, by the name the skeleton declares. Without it NO skin is set — every slot resolves ' +
3723
- 'through the default skin alone, so a slot whose art lives only in a named skin draws nothing. A name the ' +
3724
- 'skeleton does not declare is refused with the ones it does. `render` records the skin in frames.json and ' +
3725
- '`check` reads it back, so a skin-A candidate is not scored against skin-B frames in silence',
3726
- slot:
3727
- 'draw only these slots, comma-separated, in the skeleton\'s draw order, on the SAME grid as the whole rig: the ' +
3728
- 'viewport is still fitted to every slot, so this frame overlays the full one pixel for pixel. A name the ' +
3729
- 'skeleton does not declare is refused with every one it does; a slot whose art lives only under another skin ' +
3730
- 'is refused naming that skin. frames.json records the subset, and `check` refuses such a set as a reference',
3731
- hide:
3732
- 'draw every slot but these, comma-separated — `--slot` the other way round, on the same grid, recorded and ' +
3733
- 'refused the same way. Not with `--slot`: the two are one statement',
3734
- max: 'longest side of a rendered frame, in pixels (default 256)',
3735
- record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
3736
- ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
3737
- ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
3738
- again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
3739
- dir:
3740
- `the directory to install into, resolved against your working directory (default \`${DEFAULT_SKILLS_DIR}\`, the ` +
3741
- 'workspace directory Codex, Gemini CLI and Antigravity read skills from)',
3742
- copy:
3743
- 'copy each skill folder instead of linking it, for a host that does not follow a linked skill folder. A copy ' +
3744
- 'is not reached by an upgrade of the package, and one that is no longer the package\'s bytes is refused by name ' +
3745
- 'on the next run — remove it and run again (default: a relative symlink, which an upgrade reaches with no ' +
3746
- 'second run)',
3747
- name: "the rig spec's own name, which the motion spec's archetype must match (default: the skeleton file's basename)",
3748
- art: 'how the written spec reaches the art, which a skeleton does not encode: `loose` names an image per ' +
3749
- "attachment, measured out of the rig spec's own images directory (--images writes it; without it, `build " +
3750
- '--images <dir>` on every rebuild), `none` states width/height only for `build --atlas-in <pack>` to ' +
3751
- 'resolve (default: loose)',
3752
- stage:
3753
- 'the setup bounding box — `skeleton.x,y,width,height` — to ADD to a skeleton that declares none. It cannot be ' +
3754
- 'derived: posing the rig gives the ANIMATED extent, which is a different number from the setup box, so this ' +
3755
- "is the caller's value, and without it the absence is carried: the spec states `\"width\": null, \"height\": " +
3756
- 'null` and the rebuild declares no stage either. ⚠️ An editor export MAY ' +
3757
- 'carry none; every editor export measured for this project carries one and ingest reads it straight through, ' +
3758
- 'so the flag is for a file that really has none rather than for editor exports as a class. ⛔ Beside a ' +
3759
- 'skeleton that already declares a box it is REFUSED rather than ignored: two sources for one value, and the ' +
3760
- 'file is the record of what was measured',
3761
- help: "show this command's flags and exit",
3762
- };
3763
-
3764
- /** The `<value>` a flag takes, for its column in a command's flag table. Absent for a boolean switch. */
3765
- const FLAG_VALUES: Record<string, string> = {
3766
- rig: '<path>',
3767
- motion: '<path>',
3768
- out: '<dir>',
3769
- images: '<dir>',
3770
- manifest: '<path>',
3771
- cut: '<name>',
3772
- cuts: '<path>',
3773
- profile: 'spine|spine-html',
3774
- atlas: '<path>',
3775
- 'atlas-in': '<file.atlas>',
3776
- 'page-size': '<px>',
3777
- padding: '<px>',
3778
- 'page-edges': 'pot|free',
3779
- 'texture-from': '<path>',
3780
- poser: 'core|spine',
3781
- reference: '<dir|skeleton.json>',
3782
- 'reference-atlas': '<path>',
3783
- bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
3784
- candidate: '<dir|skeleton.json>',
3785
- frames: '<dir>',
3786
- fps: '<n>',
3787
- viewport: '<x,y,w,h>',
3788
- framing: 'per-shot|shared',
3789
- as: '<name>',
3790
- json: '<out>',
3791
- frame: '<path>',
3792
- scale: '<min,max>',
3793
- rotation: '<min,max>',
3794
- 'max-residual': '<0..1>',
3795
- anchor: '<pose.json>',
3796
- hinge: '<min,max>',
3797
- stretch: '<ratio>',
3798
- 'min-visible': '<0..1>',
3799
- passes: '<n>',
3800
- 'anchor-residual': '<0..1>',
3801
- 'inward-lever': '<px>',
3802
- animation: '<name>',
3803
- skin: '<name>',
3804
- slot: '<name[,name…]>',
3805
- hide: '<name[,name…]>',
3806
- max: '<px>',
3807
- record: '<result.json>',
3808
- ballot: '<ballot.html>',
3809
- ledger: '<votes.jsonl>',
3810
- name: '<n>',
3811
- art: 'loose|none',
3812
- stage: '<x,y,w,h>',
3813
- dir: '<path>',
3814
- };
3815
-
3816
- interface CommandDoc {
3817
- name: string;
3818
- /** One or more invocation forms, each already spelling the command name. */
3819
- usage: string[];
3820
- /** Flag names (into FLAG_MEANINGS/FLAG_VALUES), in display order. `--help` is appended automatically. */
3821
- flags: string[];
3822
- /**
3823
- * Per-command wording for a flag whose value or meaning genuinely differs here.
3824
- *
3825
- * ⚠️ The default above it — one meaning per flag name, everywhere — is the rule
3826
- * and this is the named exception to it, not a second table. What earns an entry
3827
- * is the criterion rather than a headcount: the flag is **shared with another
3828
- * command**, and it means something different in this one. Examples, and not an
3829
- * inventory: `--out` is a directory of artifacts to `build`, a directory of specs
3830
- * to `ingest`, a directory of pictures to
3831
- * `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
3832
- * was RECORDED at to `check`, which reads it off a sidecar, and the rate to
3833
- * SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
3834
- * everywhere except `vote`, which is the one command that takes several and is
3835
- * the reason there is a ballot at all. Writing any of them as one sentence
3836
- * covering every command would leave every command's own help less true.
3837
- *
3838
- * ⛔ The other side of the criterion, which is the one that keeps this from
3839
- * becoming the second table it says it is not: a flag no other command takes has
3840
- * nothing to differ FROM, so its wording belongs in `FLAG_MEANINGS` /
3841
- * `FLAG_VALUES` above and an entry here for it buys only a second place to look.
3842
- * Both halves are read off `--help` by `CLI71` in `selftest.ts`, which is why
3843
- * this sentence no longer counts anything: it said *"three"* where #605 counted
3844
- * nine, and nothing had ever compared the two.
3845
- */
3846
- overrides?: Record<string, { value?: string; meaning?: string }>;
3847
- /**
3848
- * Lines printed under the flag table: what this command's own figures mean.
3849
- *
3850
- * ⚠️ Not a second place to describe a flag. It exists for what is true of the
3851
- * **command** and of no flag it takes — and the case that earned it is issue
3852
- * #678: `pose` and `chainfit` each say *"it is a reporting threshold, not a
3853
- * pass bar"* on the flag that carries their threshold, and `check` has no such
3854
- * flag, so its page said nothing at all about whether any of its figures is a
3855
- * bar to beat. An agent reading `slot drift worst 3.7 px` off a correct rig had
3856
- * no page to consult and no exit code to read it in.
3857
- */
3858
- notes?: string[];
3859
- }
3860
-
3861
- const COMMANDS: CommandDoc[] = [
3862
- {
3863
- name: 'build',
3864
- usage: [
3865
- 'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
3866
- `rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] (parts onto shared pages, written into --out)`,
3867
- 'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
3868
- 'rigc build --cut <name> --cuts <cuts.json>',
3869
- ],
3870
- flags: [
3871
- 'rig',
3872
- 'motion',
3873
- 'out',
3874
- 'manifest',
3875
- 'images',
3876
- 'copy-images',
3877
- 'pack',
3878
- 'page-size',
3879
- 'padding',
3880
- 'page-edges',
3881
- 'atlas-in',
3882
- 'cut',
3883
- 'cuts',
3884
- 'profile',
3885
- ],
3886
- },
3887
- {
3888
- name: 'explain',
3889
- usage: [
3890
- 'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--poser core|spine] (it never gates, and writes nothing)',
3891
- 'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
3892
- 'rigc explain --cut <name> --cuts <cuts.json>',
3893
- ],
3894
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser'],
3895
- notes: [
3896
- 'this line said "the same arguments as build, minus --profile" and was false in both',
3897
- 'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
3898
- 'command read what `ingest --art none` writes was reachable and undocumented, while',
3899
- '--pack, --page-size, --padding, --page-edges and --copy-images are build\'s and do nothing here —',
3900
- 'they decide what is WRITTEN, and this command writes nothing. What it takes is listed',
3901
- 'above, and that is now the whole of it.',
3902
- ],
3903
- },
3904
- {
3905
- name: 'validate',
3906
- usage: [
3907
- 'rigc validate <dir | skeleton.json> [--atlas <path>] [--profile spine|spine-html]',
3908
- 'rigc validate --cut <name> --cuts <cuts.json> (also re-derives declared durations)',
3909
- ],
3910
- flags: ['atlas', 'profile', 'cut', 'cuts', 'rig', 'motion', 'out', 'manifest', 'images'],
3911
- },
3912
- {
3913
- name: 'ingest',
3914
- usage: ['rigc ingest <skeleton.json> --out <dir> [--name <n>] [--art loose|none] [--images <dir>] [--stage x,y,w,h]'],
3915
- flags: ['out', 'name', 'art', 'images', 'stage'],
3916
- overrides: {
3917
- out: {
3918
- value: '<dir>',
3919
- meaning: 'directory to write rig.json, motion.json and findings.json into — the two specs that rebuild this skeleton',
3920
- },
3921
- images: {
3922
- value: '<dir>',
3923
- meaning:
3924
- "WRITE the rig spec's own images directory, spelled relative to --out, so the rebuild is a plain `build " +
3925
- '--rig … --motion … --out …` with no flag. ⚠️ The opposite direction from `build --images`, which ' +
3926
- 'OVERRIDES that field: this one fills it in. Without it the field is left out and every `image` resolves ' +
3927
- 'against --out itself. Refused together with --art none, which writes no `image` for it to be the base of',
3928
- },
3929
- },
3930
- },
3931
- {
3932
- name: 'diff',
3933
- usage: ['rigc diff <candidate.json> <reference.json> [--as <candidate>=<reference>]… [--json <out>]'],
3934
- flags: ['as', 'json'],
3935
- overrides: {
3936
- as: {
3937
- value: '<candidate>=<reference>',
3938
- meaning:
3939
- 'pair a candidate animation with a reference one, so the name-agnostic `animations` block can be ' +
3940
- 'measured over shots the two files call different things. Repeatable, one pair each. An INPUT and never ' +
3941
- 'derived: two skeletons cannot say which of their shots are the same shot. Without it the block appears ' +
3942
- 'only when each side has exactly one animation, which pairs by position, and is otherwise absent rather ' +
3943
- 'than guessed',
3944
- },
3945
- },
3946
- notes: [
3947
- 'the `animations` block reads two figures once something has paired the shots, exactly as',
3948
- '`bones` and `slots` do: name-matched, where `names` lives, and name-agnostic over the pair.',
3949
- 'A candidate that followed a brief withholding the animation name reads `count` 1/1 and 0.000',
3950
- 'on every other name-matched measure — including `duration` and `key_counts` it may have got',
3951
- 'exactly right — so read the pair and not the section mean.',
3952
- ],
3953
- },
3954
- {
3955
- name: 'check',
3956
- usage: ['rigc check --candidate <dir | skeleton.json> --frames <dir> [flags]'],
3957
- flags: ['candidate', 'frames', 'atlas', 'texture-from', 'fps', 'viewport', 'framing', 'as', 'skin', 'poser', 'all-frames', 'json', 'out'],
3958
- overrides: {
3959
- skin: {
3960
- meaning:
3961
- 'pose the CANDIDATE under this skin, by the name it declares. Without it no skin is set and the ' +
3962
- 'default skin alone is compared, which for a multi-skin rig is a comparison that can see none of the ' +
3963
- 'contested art. The frames are checked back: a set whose frames.json records a different skin is ' +
3964
- 'REFUSED by name, and one that records none says so in the report rather than pretending to agree',
3965
- },
3966
- out: {
3967
- value: '<dir>',
3968
- meaning:
3969
- 'also write the PICTURE each listed frame\'s figures came from, as <dir>/<set>/f####.png: reference, ' +
3970
- 'candidate, difference and overlay side by side at the comparison grid\'s native size, with the table\'s ' +
3971
- 'figures burned in and one row per slot under them, beside a frames.json that says what they are pictures ' +
3972
- 'of. The frames are the ones the table lists, so --all-frames writes every compared one. Each <dir>/<set>/ ' +
3973
- 'is cleared first; a file at <dir>, or a directory that is or holds --frames, is refused. See ' +
3974
- 'docs/AUTHORING.md §9.2.1',
3975
- },
3976
- },
3977
- notes: [
3978
- 'every figure here is a reporting threshold, not a pass bar. Nothing in this report',
3979
- 'grades, no number has to beat anything, and the exit code says only whether the',
3980
- 'comparison could be MADE: 0 when it ran — including the build with every easing',
3981
- 'reversed, which is the defect this command exists for — 1 when it could not (frames',
3982
- 'that are not there, a skin the frames do not record, a candidate that will not load),',
3983
- '2 on the flags.',
3984
- '',
3985
- 'So read a figure against a floor you measured yourself: render the first green build',
3986
- 'and keep its frames, then check every later build against them. The identity run of',
3987
- 'that pair is the floor, and it is not zero — docs/AUTHORING.md §9.2 states it, what',
3988
- 'it comes from, and which column separates a wrong curve from a moved key.',
3989
- ],
3990
- },
3991
- {
3992
- name: 'bench',
3993
- usage: [`rigc bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>] [flags]`],
3994
- flags: ['candidate', 'atlas', 'frames', 'profile', 'bones', 'all-frames', 'all-bones', 'json'],
3995
- overrides: {
3996
- bones: {
3997
- meaning:
3998
- 'also run the stage-3 per-frame bone world-transform distance against each of the rung\'s reference ' +
3999
- `skeletons, with this correspondence (or \`identity\`), at ${PROTOCOL_FPS} fps. Reports; gates nothing — ` +
4000
- 'for another sampling rate call `rigc bonedist` directly, where --fps means only that',
4001
- },
4002
- },
4003
- },
4004
- {
4005
- name: 'bonedist',
4006
- usage: [
4007
- `rigc bonedist --candidate <dir | skeleton.json> --reference <dir | skeleton.json> --bones <path | ${IDENTITY_CORRESPONDENCE}> [--fps ${PROTOCOL_FPS}] [--all-bones] [--json <out>]`,
4008
- ],
4009
- flags: ['candidate', 'atlas', 'reference', 'reference-atlas', 'bones', 'fps', 'all-bones', 'json'],
4010
- overrides: {
4011
- fps: { meaning: `the rate both skeletons are sampled at, from t=0 over their own durations (default ${PROTOCOL_FPS})` },
4012
- },
4013
- },
4014
- {
4015
- name: 'render',
4016
- usage: [
4017
- 'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--poser core|spine] [--out render/]',
4018
- 'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
4019
- ],
4020
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'poser', 'out'],
4021
- overrides: {
4022
- out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
4023
- fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
4024
- },
4025
- },
4026
- {
4027
- name: 'preview',
4028
- usage: ['rigc preview --candidate <dir | skeleton.json> [--candidate <another> …] [--animation <name>] [--out preview.html]'],
4029
- flags: ['candidate', 'atlas', 'animation', 'out'],
4030
- overrides: {
4031
- candidate: {
4032
- value: '<dir|skeleton.json>',
4033
- meaning:
4034
- 'a compiled skeleton: a directory holding skeleton.json + skeleton.atlas, or a skeleton.json path. Repeat it ' +
4035
- 'for one page with a pane per candidate, in the order given; the same skeleton twice is refused. Each ' +
4036
- 'header carries the line `rigc validate <dir>` prints for that candidate, measured when the page is written',
4037
- },
4038
- atlas: { meaning: "the candidate's atlas, when it is not beside the skeleton — one candidate only" },
4039
- animation: {
4040
- meaning:
4041
- "the animation to start on (default: each candidate's own first). With several candidates every one of " +
4042
- 'them must have it, or the run is refused naming the one that does not',
4043
- },
4044
- out: {
4045
- value: '<file>',
4046
- meaning: 'the .html file to write (default `preview.html`); a directory means "the default name in here"',
4047
- },
4048
- },
4049
- },
4050
- {
4051
- name: 'pose',
4052
- usage: [
4053
- `rigc pose --images <dir> --frame <path> [--scale ${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}] [--rotation -180,180] [--out ${DEFAULT_POSE_OUT}]`,
4054
- ],
4055
- flags: ['images', 'frame', 'scale', 'rotation', 'max-residual', 'out'],
4056
- overrides: {
4057
- images: { value: '<dir>', meaning: 'the loose part PNGs to place; every `.png` in it is a part, in name order' },
4058
- out: {
4059
- value: '<file>',
4060
- meaning: `the .json report to write (default \`${DEFAULT_POSE_OUT}\`); a directory means "the default name in here"`,
4061
- },
4062
- },
4063
- },
4064
- {
4065
- name: 'chainfit',
4066
- usage: [
4067
- `rigc chainfit --candidate <dir | skeleton.json> --images <dir> --frame <path> [--anchor pose.json] [--out ${DEFAULT_CHAINFIT_OUT}]`,
4068
- ],
4069
- flags: [
4070
- 'candidate',
4071
- 'images',
4072
- 'frame',
4073
- 'anchor',
4074
- 'hinge',
4075
- 'stretch',
4076
- 'min-visible',
4077
- 'max-residual',
4078
- 'passes',
4079
- 'anchor-residual',
4080
- 'inward-lever',
4081
- 'scale',
4082
- 'rotation',
4083
- 'out',
4084
- ],
4085
- overrides: {
4086
- images: {
4087
- value: '<dir>',
4088
- meaning:
4089
- "where each attachment's image name resolves to a loose PNG. ⚠️ NOT a part list the way `pose --images` " +
4090
- 'is one — the candidate decides what the parts are, so extra PNGs in here are simply unused and a name ' +
4091
- 'the directory lacks is refused by name',
4092
- },
4093
- scale: {
4094
- meaning:
4095
- 'the scale window the INTERNAL anchor pass searches, as frame pixels per part pixel (default ' +
4096
- `\`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`). Refused together with --anchor, which means there is no internal pass`,
4097
- },
4098
- rotation: {
4099
- meaning:
4100
- 'the rotation window the INTERNAL anchor pass searches, in screen degrees (default `-180,180`). Refused ' +
4101
- 'together with --anchor — the chains\' own window is --hinge',
4102
- },
4103
- out: {
4104
- value: '<file>',
4105
- meaning: `the .json report to write (default \`${DEFAULT_CHAINFIT_OUT}\`); a directory means "the default name in here"`,
4106
- },
4107
- },
4108
- },
4109
- {
4110
- name: 'vote',
4111
- usage: [
4112
- `rigc vote --candidate <dir | skeleton.json> --candidate <…> [--candidate …] [--animation <name>] [--out ${DEFAULT_BALLOT}]`,
4113
- `rigc vote --record <result.json> [--ballot ${DEFAULT_BALLOT}] [--ledger ${DEFAULT_LEDGER}] [--again]`,
4114
- ],
4115
- flags: ['candidate', 'animation', 'out', 'record', 'ballot', 'ledger', 'again'],
4116
- overrides: {
4117
- candidate: {
4118
- value: '<dir|skeleton.json>',
4119
- meaning: `repeat it ${MIN_CANDIDATES}–${MAX_CANDIDATES} times — one compiled artifact per pane, labelled A, B, C, D in the order given`,
4120
- },
4121
- animation: {
4122
- meaning:
4123
- 'the one animation every pane plays (default: the first of candidate A). A candidate that does not have ' +
4124
- 'it is refused — two panes playing two animations is not a comparison',
4125
- },
4126
- out: {
4127
- value: '<file>',
4128
- meaning: `the .html ballot to write (default \`${DEFAULT_BALLOT}\`); a directory means "the default name in here"`,
4129
- },
4130
- },
4131
- },
4132
- {
4133
- name: 'skills',
4134
- usage: [`rigc skills install [--dir ${DEFAULT_SKILLS_DIR}] [--copy] (every skill this package ships, where an agent host looks)`],
4135
- flags: ['dir', 'copy'],
4136
- notes: [
4137
- 'the skills installed are the skills/ directory of the package this command runs from,',
4138
- 'never whatever the working directory holds. Each becomes <dir>/<name>: a relative',
4139
- 'symlink into that folder, or with --copy a copy of it. An entry already there that',
4140
- 'is not a link to the same folder — or, with --copy, not the same bytes — is refused',
4141
- 'by name, exit 1, and nothing is written; a run over only what this command made has',
4142
- 'nothing to do and exits 0. Codex, Gemini CLI and Antigravity read',
4143
- `<workspace>/${DEFAULT_SKILLS_DIR}; Claude Code installs the plugin instead (README,`,
4144
- '"Install it into your agent").',
4145
- ],
4146
- },
4147
- ];
4148
-
4149
- const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
4150
-
4151
- /** `rigc <command> --help`: that command's own usage line(s) and flag table. */
4152
- function commandHelp(name: string): string {
4153
- const doc = COMMANDS.find((c) => c.name === name);
4154
- if (!doc) throw new Error(`internal: no help text for command "${name}"`);
4155
- const keys = [...doc.flags, 'help'];
4156
- const value = (key: string): string | undefined => doc.overrides?.[key]?.value ?? FLAG_VALUES[key];
4157
- const meaning = (key: string): string => doc.overrides?.[key]?.meaning ?? FLAG_MEANINGS[key];
4158
- const labels = keys.map((key) => `--${key}${value(key) ? ` ${value(key)}` : ''}`);
4159
- const width = Math.max(...labels.map((l) => l.length)) + 2;
4160
- return [
4161
- 'usage:',
4162
- ...doc.usage.map((u) => ` ${u}`),
4163
- '',
4164
- 'flags:',
4165
- ...keys.map((key, i) => ` ${labels[i].padEnd(width)}${meaning(key)}`),
4166
- ...(doc.notes === undefined ? [] : ['', ...doc.notes]),
4167
- ].join('\n');
4168
- }
4169
-
4170
- const USAGE = [
4171
- 'rigc — the rig compiler',
4172
- '',
4173
- '(from a source checkout: `bun cli.ts <command>` is the same as `rigc <command>`)',
4174
- '',
4175
- 'usage:',
4176
- ...COMMANDS.flatMap((c) => c.usage.map((u) => ` ${u}`)),
4177
- '',
4178
- ' rigc <command> --help that command\'s own flag table',
4179
- ' rigc --version print the installed version (-v works too)',
4180
- '',
4181
- 'build, validate and bench take --profile spine|spine-html:',
4182
- ' spine is this valid Spine 4.3 that any runtime plays correctly?',
4183
- ` THE DEFAULT — ${assertionCountForProfile('spine')} rules, and the question the output answers when`,
4184
- ' you import it into the Spine editor.',
4185
- ' spine-html the above, plus this project\'s renderer and archetype policy:',
4186
- ` all ${assertionCountForProfile('spine-html')} rules, opt-in. Those extra ` +
4187
- `${assertionCountForProfile('spine-html') - assertionCountForProfile('spine')} fire on real, correct,`,
4188
- ' editor-produced Spine data, so they are somebody\'s policy rather',
4189
- ' than anybody\'s validity.',
4190
- '',
4191
- 'Every report names the profile that judged it and lists, on PROF lines, the',
4192
- 'rules that profile left out.',
4193
- '',
4194
- 'check renders the candidate onto the reference frames\' own pixel grid, fitting it',
4195
- 'there by its own drawn pixels, and compares. It reads the frames and never the',
4196
- 'reference skeleton, so it belongs INSIDE an authoring loop — the validator cannot',
4197
- 'see a wrong animation and this can. See `rigc check --help` for its flags.',
4198
- '',
4199
- 'render and preview are how you LOOK at a build, and they need no reference at all:',
4200
- ' rigc render --candidate <the dir build --out wrote> PNG frames + a contact sheet',
4201
- ' rigc preview --candidate <the same dir> one .html file that plays it',
4202
- 'A rig with its head off its torso passes the gate and steps cleanly — the offsets',
4203
- 'are the ones you asked for — so looking is the only thing that catches it. render',
4204
- 'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
4205
- 'it in the official Spine Web Player, which is also the interop proof.',
4206
- '',
4207
- 'ingest runs build backwards: it reads a Spine 4.3 skeleton.json and writes the rig',
4208
- 'spec and motion spec that rebuild it, so an existing skeleton becomes a starting',
4209
- 'point instead of something to retype:',
4210
- ' rigc ingest hero.json --out specs/ --images parts/ rig.json + motion.json',
4211
- 'The contract is an equality, not a rulebook: build(ingest(x)) is x, byte for byte.',
4212
- 'It reads the skeleton and nothing else — no .spine project, no binary .skel, no',
4213
- 'atlas — so how the spec reaches the art is the caller\'s (--art) and is not guessed.',
4214
- 'A setup stage the skeleton states is read; one it does not state is carried as',
4215
- 'absent, and --stage is how a caller adds a box to such a file — beside a box the',
4216
- 'file states, the flag is refused rather than ignored. --images <dir> WRITES the rig',
4217
- 'spec\'s own images directory, relative to --out, so the rebuild needs no flag.',
4218
- 'Everything the spec format cannot hold is printed as a named finding and',
4219
- 'exits non-zero, with both files still written, because a spec plus a list of what',
4220
- 'is missing from it beats no spec at all.',
4221
- '',
4222
- 'pose runs the other way round from everything above: it reads a picture you already',
4223
- 'have — one key pose — and reports where each loose part PNG sits in it (x, y, rotation,',
4224
- 'scale) so an agent can state those poses in a spec by construction:',
4225
- ' rigc pose --images parts/ --frame poseA.png pose.json, one entry per part',
4226
- 'It grades nothing and no pass bar attaches to its numbers. The residual is a trust',
4227
- 'signal, and where two placements are equally good it reports BOTH rather than picking —',
4228
- 'two identical limbs look exactly like that. A part that matches nowhere, a part the',
4229
- 'canvas cannot contain and a part whose rotation is a free degree of freedom are each',
4230
- 'named as such. See `rigc pose --help`.',
4231
- '',
4232
- 'chainfit reads the half of that picture pose refuses. It is the same question with',
4233
- 'one more input — the candidate rig — and that input buys two things: draw order, so',
4234
- 'the pixels another part covers are EXCLUDED from a part\'s residual instead of',
4235
- 'charged to it, and hierarchy, so a child of a placed bone is searched over one hinge',
4236
- 'instead of four degrees of freedom:',
4237
- ' rigc chainfit --candidate build/ --images parts/ --frame poseA.png',
4238
- 'Every residual is over the part\'s VISIBLE pixels and comes with the `visibleShare` it',
4239
- 'was computed on, so a mostly-hidden answer carries its own uncertainty. It grades',
4240
- 'nothing either: a part too far behind the others is refused by the visibility floor,',
4241
- 'a limb with no trusted part on it or above it is refused `no-anchor`, and two hinge',
4242
- 'answers that explain the picture equally well are both reported. See',
4243
- '`rigc chainfit --help`.',
4244
- '',
4245
- 'vote is the same page with two to four builds in it and an answer coming back:',
4246
- ' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
4247
- ' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
4248
- 'Reach for it where the instruments have run out — a choice with no reference behind',
4249
- 'it, two fits that measure the same. The panes carry no paths, a tie is a recorded',
4250
- 'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
4251
- 'refused by name instead of appended.',
4252
- '',
4253
- 'skills install puts the agent skills this package ships where an agent host looks',
4254
- 'for them, since none of them reads node_modules:',
4255
- ` rigc skills install relative links in ${DEFAULT_SKILLS_DIR}, which Codex, Gemini CLI`,
4256
- ' and Antigravity read; --copy writes the folders instead',
4257
- 'An entry already there that this command did not make is refused by name and',
4258
- 'nothing is written; a second run has nothing to do. See `rigc skills --help`.',
4259
- '',
4260
- 'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
4261
- ' "manifest": "..." (optional) } }, with every path',
4262
- 'resolved relative to the cuts.json file itself.',
4263
- ].join('\n');
4264
-
4265
- const [command, ...rest] = process.argv.slice(2);
4266
- try {
4267
- if (command === undefined) {
4268
- console.error(USAGE);
4269
- process.exit(2);
4270
- }
4271
- if (command === '--version' || command === '-v') {
4272
- console.log(readVersion());
4273
- process.exit(0);
4274
- }
4275
- if (command === '--help' || command === '-h') {
4276
- console.log(USAGE);
4277
- process.exit(0);
4278
- }
4279
- if (!KNOWN_COMMANDS.includes(command)) {
4280
- throw new UsageError(`unknown command: ${command}`);
4281
- }
4282
-
4283
- const { flags, lists, positional } = parseArgs(rest, REPEATABLE_FLAGS[command]);
4284
- if (flags.help !== undefined) {
4285
- console.log(commandHelp(command));
4286
- process.exit(0);
4287
- }
4288
- if (command === 'build') cmdBuild(flags);
4289
- else if (command === 'ingest') cmdIngest(flags, positional);
4290
- else if (command === 'validate') cmdValidate(flags, positional);
4291
- else if (command === 'explain') cmdExplain(flags);
4292
- else if (command === 'diff') cmdDiff(flags, lists, positional);
4293
- else if (command === 'check') cmdCheck(flags);
4294
- else if (command === 'bench') cmdBench(flags, positional);
4295
- else if (command === 'bonedist') cmdBoneDist(flags);
4296
- else if (command === 'render') cmdRender(flags);
4297
- else if (command === 'preview') cmdPreview(flags, lists.candidate ?? []);
4298
- else if (command === 'pose') cmdPose(flags);
4299
- else if (command === 'chainfit') cmdChainFit(flags);
4300
- else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
4301
- else if (command === 'skills') cmdSkills(flags, positional);
4302
- } catch (err) {
4303
- if (err instanceof UsageError) {
4304
- console.error(`rigc: ${err.message}\n\n${USAGE}`);
4305
- process.exit(2);
4306
- }
4307
- // A ballot refuses on its arguments, like a usage error, but its messages are
4308
- // long enough that reprinting the whole usage under them buries the reason.
4309
- if (err instanceof BallotError) {
4310
- console.error(`rigc vote: ${err.message}`);
4311
- process.exit(2);
4312
- }
4313
- if (err instanceof CompileError) {
4314
- console.error(`rigc compile error: ${err.message}`);
4315
- // The drops the compile recorded before it stopped, in the same line the
4316
- // green build prints (issue #671). They were reported from the compile
4317
- // RESULT alone, so the run that failed BECAUSE a file was missing was the
4318
- // one run that never named the file. On stderr with the refusal rather than
4319
- // on stdout, so redirecting one stream does not separate a fact from the
4320
- // sentence it explains.
4321
- for (const dropped of err.droppedStates ?? []) console.error(dropLine(dropped));
4322
- process.exit(1);
4323
- }
4324
- if (err instanceof CheckError) {
4325
- console.error(`rigc check error: ${err.message}`);
4326
- process.exit(1);
4327
- }
4328
- if (err instanceof BoneDistError) {
4329
- console.error(`rigc bonedist error: ${err.message}`);
4330
- process.exit(1);
4331
- }
4332
- // Like a usage error in kind — a missing directory, an unreadable frame — but
4333
- // its messages name a path and a reason, and reprinting the whole usage under
4334
- // them buries that.
4335
- if (err instanceof PoseError) {
4336
- console.error(`rigc pose: ${err.message}`);
4337
- process.exit(2);
4338
- }
4339
- // A refusal of the invocation, like the two below it, and exit 2 for the same
4340
- // reason: nothing was posed and nothing was written, so it is the command line
4341
- // that has to change (issue #697).
4342
- if (err instanceof ExplainError) {
4343
- console.error(`rigc explain: ${err.message}`);
4344
- process.exit(2);
4345
- }
4346
- // Same kind as a PoseError, and printed the same way for the same reason: the
4347
- // messages name a path, a bone or an attachment, and reprinting the whole
4348
- // usage under them buries the one line that says what to change.
4349
- if (err instanceof ChainFitError) {
4350
- console.error(`rigc chainfit: ${err.message}`);
4351
- process.exit(2);
4352
- }
4353
- // A usage error in kind — an option that contradicts the file it was given —
4354
- // and exit 2 for that reason rather than 1: nothing was compiled and nothing
4355
- // was written, so it is the invocation that has to change (issue #626). It is
4356
- // raised in `src/ingest.ts` rather than here because the library caller who
4357
- // passes the same contradiction deserves the same refusal, and one rule in one
4358
- // place is what stops the two from drifting apart.
4359
- if (err instanceof IngestError) {
4360
- console.error(`rigc ingest: ${err.message}`);
4361
- process.exit(2);
4362
- }
4363
- // A page or frame that is not a PNG rigc can read, from any command that
4364
- // opens one (`render`, `check`, `preview`, …) — issue #732. The sentence is
4365
- // the one reader's and already names the file, what it is and what rigc
4366
- // reads; a stack under it is the tool describing its own internals instead.
4367
- // Exit 1, like a compile error: the invocation was fine, a file was not.
4368
- // A pose that is not finite (issues #864, #873): the invocation was fine and
4369
- // the skeleton posed a NaN or an infinity, so exit 1 like a file that is not
4370
- // a PNG. Raised by the geometry export and by the framing — the one sentence
4371
- // naming the bone or vertex and its value — before the first file is written.
4372
- // A pose whose every drawn vertex sits at one point (issue #997), from
4373
- // `render` or `check`: exit 2 like *nothing to draw*, the other framing
4374
- // refusal, because the usual fix is the invocation's — `--skin` — and nothing
4375
- // was written. A `GeometryError` in kind, so it is caught before that.
4376
- if (err instanceof UnframeablePoseError) {
4377
- console.error(`rigc ${command}: ${err.message}`);
4378
- process.exit(2);
4379
- }
4380
- if (err instanceof GeometryError) {
4381
- console.error(`rigc render: ${err.message}`);
4382
- process.exit(1);
4383
- }
4384
- // `--poser core` on an input the core cannot carry (issue #968): a refusal of
4385
- // the invocation, nothing written, and the message names the input and why —
4386
- // the usage under it would bury that. Without the flag the same refusal is a
4387
- // fallback to spine-core, named on the render's `poser` line instead.
4388
- if (err instanceof PoserChoiceError) {
4389
- console.error(`rigc ${command}: ${err.message}`);
4390
- process.exit(2);
4391
- }
4392
- if (err instanceof NotAPngError) {
4393
- console.error(`rigc: ${err.message}`);
4394
- process.exit(1);
4395
- }
4396
- // An input posed through spine-core — a Spine export, `--poser spine`, a
4397
- // fallback — on a run where the runtime cannot be used (issue #1014). The
4398
- // sentence names the input and why it needs the runtime; exit 1, like a file
4399
- // that is not a PNG: the invocation was fine and the run could not pose it.
4400
- // A candidate with no atlas beside it that has to be read through one (issue
4401
- // #1020): a refusal of the invocation like a missing atlas on an export, exit
4402
- // 2 and nothing written, and the message names the file and why it is needed.
4403
- if (err instanceof CandidateAtlasError) {
4404
- console.error(`rigc ${command}: ${err.message}`);
4405
- process.exit(2);
4406
- }
4407
- // A skeleton spine-core could not load against the atlas beside it (issue #1033) — on a rigc build, a
4408
- // skeleton.json or an atlas from another build beside this one's model document. The same class as the
4409
- // missing atlas above: nothing was posed or written, and it is the directory the command was pointed at that
4410
- // has to change. The message names both files, the reason the runtime drew them and the runtime's own words.
4411
- // Since issue #1042 also `bonedist` and `bench --bones` on either side, and a page a candidate is drawn from that
4412
- // is not there, whichever poser draws it.
4413
- if (err instanceof CandidatePairError) {
4414
- console.error(`rigc ${command}: ${err.message}`);
4415
- process.exit(2);
4416
- }
4417
- if (err instanceof SpineRuntimeError) {
4418
- console.error(`rigc ${command}: ${err.message}`);
4419
- process.exit(1);
4420
- }
4421
- // An install refused before its first write (issue #831): the invocation was
4422
- // fine and an entry on disk was not what this command would write, so exit 1
4423
- // like a file that is not a PNG. The message names every such entry, what is
4424
- // there and what was required; the usage under it would bury that.
4425
- if (err instanceof SkillsInstallError) {
4426
- console.error(`rigc skills install: ${err.message}`);
4427
- process.exit(1);
4428
- }
4429
- throw err;
4430
- }
34
+ import { PROFILE_RULES, SPINE_COMMAND_REFUSALS, SPINE_COMMAND_RUNS } from './src/cli/spine_commands.ts';
35
+ import { CORE_COMMAND_RUNS } from './src/cli/core_commands.ts';
36
+ import { runCli } from './src/cli/shared.ts';
37
+ // The Spine side of the seam (issue #1052): loading the modules that link spine-core registers what they pose and read
38
+ // through it — an export's poser and loader, `--poser spine`, a fallback, the deform survey through the runtime — into
39
+ // `src/spine_side.ts`, which the commands below reach it through.
40
+ import './src/render.ts';
41
+ import './src/deformmeasure.ts';
42
+
43
+ /**
44
+ * One entry of a cuts.json, every path relative to the cuts.json file — the
45
+ * shape `src/cli/shared.ts` reads, re-exported where it was always declared.
46
+ */
47
+ export type { CutEntry, CutTable } from './src/cli/shared.ts';
48
+
49
+ // ⭐ Every command, every refusal and every exit code as they always were: the dispatch, the documentation and the
50
+ // bodies moved to `src/cli/` unchanged (issue #1052), and this entry registers all of them. `cli_core.ts` registers the
51
+ // commands whose bodies reach nothing of the runtime, and links none of it.
52
+ runCli(
53
+ {
54
+ checkout: 'cli.ts',
55
+ linksRuntime: true,
56
+ runs: { ...CORE_COMMAND_RUNS, ...SPINE_COMMAND_RUNS },
57
+ profileRules: PROFILE_RULES,
58
+ refusals: SPINE_COMMAND_REFUSALS,
59
+ },
60
+ process.argv.slice(2),
61
+ );