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