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