spine-rigc 0.9.0 → 0.10.0
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/README.md +25 -13
- package/cli.ts +313 -10
- package/docs/AUTHORING.md +174 -9
- package/docs/INGEST.md +1117 -0
- package/docs/MOTION.md +9 -0
- package/docs/SPEC_COVERAGE.md +9 -2
- package/package.json +2 -1
- package/src/atlas.ts +871 -0
- package/src/bonedist.ts +632 -0
- package/src/check.ts +5 -2
- package/src/compile.ts +359 -52
- package/src/diff.ts +269 -1
- package/src/render.ts +87 -2
- package/src/types.ts +42 -1
- package/src/validate.ts +45 -22
package/README.md
CHANGED
|
@@ -30,6 +30,8 @@ parser and a list of named assertions all come back green.
|
|
|
30
30
|
| You have | You run | You get |
|
|
31
31
|
| --- | --- | --- |
|
|
32
32
|
| part PNGs, a rig spec and a motion spec | `rigc build` | `skeleton.json` + `skeleton.atlas` — or a failure named by rule, and **nothing on disk** |
|
|
33
|
+
| the same, and one texture instead of many | `rigc build --pack` | the parts arranged onto shared atlas pages, written beside the skeleton — losslessly, so the picture is the picture |
|
|
34
|
+
| a pack somebody already made | `rigc build --atlas-in` | the same skeleton, with every part resolved to a region of that atlas — or a named refusal, never a part that silently does not draw |
|
|
33
35
|
| a compiled rig | `rigc render` | every animation as PNG frames, plus one labelled contact sheet of the whole shot |
|
|
34
36
|
| a compiled rig | `rigc preview` | one self-contained `.html` that plays it in Spine's own web player |
|
|
35
37
|
| two to four compiled rigs | `rigc vote` | one ballot page a human picks from, and the answer checked into a ledger |
|
|
@@ -81,10 +83,11 @@ run a list of named assertions, and **write nothing unless all of them are green
|
|
|
81
83
|
|
|
82
84
|
## Install
|
|
83
85
|
|
|
84
|
-
📦 **rigc measures loose PNGs directly
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
📦 **rigc measures loose PNGs directly, and emits one atlas page per image unless
|
|
87
|
+
you ask otherwise.** `rigc build --pack` arranges every part onto shared pages and
|
|
88
|
+
writes them into `--out`; `--atlas-in` builds against a pack somebody else made.
|
|
89
|
+
Both are opt-in and both are narrow — no trimming, no rotation, no scaling — and
|
|
90
|
+
[AUTHORING §0.1–§0.2](docs/AUTHORING.md) states the limits before you hit them.
|
|
88
91
|
|
|
89
92
|
rigc runs on [Bun](https://bun.sh). The package ships its TypeScript sources and
|
|
90
93
|
Bun runs them, so there is no build step and no `dist/` that can drift from the
|
|
@@ -379,6 +382,8 @@ commands take it and what its default is.
|
|
|
379
382
|
| Command | Does |
|
|
380
383
|
| --- | --- |
|
|
381
384
|
| `build --rig … --motion … --out …` | compiles, gates, and **writes only if the gate is green**. `--images <dir>` says where the rig spec's `image` names resolve, `--manifest` adds measured art, and `--copy-images` copies every page PNG into `--out` so the directory is self-contained |
|
|
385
|
+
| `build … --pack` | the same build with every part arranged onto **shared** atlas pages, written into `--out` — losslessly, and gated a second time as the pair that ships. `--page-size` and `--padding` tune it |
|
|
386
|
+
| `build … --atlas-in <file.atlas>` | the same build with every part resolved to a **region of an existing pack** instead of a loose PNG; a name the atlas lacks, a size the spec disagrees with or a rectangle off its page is refused by name |
|
|
382
387
|
| `validate <dir>` | re-gates artifacts already on disk |
|
|
383
388
|
| `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
|
|
384
389
|
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/` |
|
|
@@ -413,6 +418,7 @@ the art. Its shape is under
|
|
|
413
418
|
| --- | --- |
|
|
414
419
|
| 📘 **[docs/AUTHORING.md](docs/AUTHORING.md)** | **the format guide, and the one to read before writing a spec.** Both input files field by field with a complete minimal example each, every field with its Spine meaning, the rules that decide what is emitted, the build → read the report → fix → repeat loop, the map from every named failure to the file that has to change, and the features rigc refuses by name so you do not spend a loop discovering them. It travels **inside the npm package**, at `node_modules/spine-rigc/docs/AUTHORING.md` |
|
|
415
420
|
| 🎞️ **[docs/MOTION.md](docs/MOTION.md)** | **the key-pose recipe.** How to get two poses, what a pair of poses does and does not fix, the in-betweening rules and where each comes from, and how to spread candidates so a ballot informs. Ships in the package too |
|
|
421
|
+
| 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
|
|
416
422
|
| 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
|
|
417
423
|
| 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | Spine 4.3's full export surface against what rigc emits and what the official examples measurably use, with the ordered gap list |
|
|
418
424
|
| 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 36 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
|
|
@@ -424,21 +430,27 @@ rigc is measured against **Spine's own official example projects** — the
|
|
|
424
430
|
`1-weight-and-mass` … `8-follow-through` series as a difficulty ladder, with spineboy
|
|
425
431
|
as the graduation exam.
|
|
426
432
|
|
|
427
|
-
🎓 **The ladder
|
|
428
|
-
spineboy graduation exam cleared
|
|
433
|
+
🎓 **The ladder is complete, 2026-08-28.** All eight numbered rungs and the
|
|
434
|
+
spineboy graduation exam are cleared and hold under the current gate, **v2.3**, every clause PASS or SKIP:
|
|
429
435
|
worst attributable slot drift **5.55 px** against a 6.0 px bar, and **0 of 124**
|
|
430
436
|
frame-change disagreements. Recompiling the same spec in a different session
|
|
431
437
|
reproduced every field of the measurement record **to the digit**. The rungs stay
|
|
432
438
|
in place as regression gates.
|
|
433
439
|
|
|
434
|
-
|
|
435
|
-
`check`'s extent tolerance ([PR #254](https://github.com/firejune/rigc/pull/254))
|
|
436
|
-
changed which box a set is measured in, and rung 7's stored candidate
|
|
440
|
+
🗓️ **One rung's pass was withdrawn and restored on 2026-09-02, and both are dated
|
|
441
|
+
facts.** `check`'s extent tolerance ([PR #254](https://github.com/firejune/rigc/pull/254))
|
|
442
|
+
changed which box a set is measured in, and rung 7's stored candidate failed **G2**
|
|
437
443
|
under it — one of its three slots draws in every set and is attributable in none,
|
|
438
|
-
and no read-down
|
|
439
|
-
|
|
440
|
-
and
|
|
441
|
-
|
|
444
|
+
and no read-down ground survived the framing change. The gate then answered the two
|
|
445
|
+
clause questions that exposed, as **v2.3**: a read-down names the framing of its
|
|
446
|
+
evidence, and a slot whose attributability is **measured** to be capped below the bar
|
|
447
|
+
reads down when everything observable about it is independently verified strict. That
|
|
448
|
+
rung's third attempt clears on those grounds, on the candidate it already had.
|
|
449
|
+
**Rungs 1–6 and 8 and the graduation exam were unaffected throughout**: each reproduces
|
|
450
|
+
its gated figures to the digit, and the 5.55 px and 0-of-124 figures above are among
|
|
451
|
+
them. Both verdicts, and the sweep of every candidate under the new gate, are in
|
|
452
|
+
[docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md)'s *PR #254 instrument re-inspection* and *gate-v2.3
|
|
453
|
+
re-inspection*.
|
|
442
454
|
|
|
443
455
|
⚠️ **What that certifies, stated exactly.** That **the tool, the guide and the
|
|
444
456
|
protocol reach the bar across a bounded series of honest attempts, each residual
|
package/cli.ts
CHANGED
|
@@ -48,10 +48,19 @@ import {
|
|
|
48
48
|
type BallotCandidateInput,
|
|
49
49
|
type BallotInput,
|
|
50
50
|
} from './src/ballot.ts';
|
|
51
|
+
import {
|
|
52
|
+
boneDistance,
|
|
53
|
+
BONEDIST_SPEC,
|
|
54
|
+
boneDistLines,
|
|
55
|
+
BoneDistError,
|
|
56
|
+
IDENTITY_CORRESPONDENCE,
|
|
57
|
+
type BoneDistReport,
|
|
58
|
+
} from './src/bonedist.ts';
|
|
51
59
|
import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
|
|
52
60
|
import { compile, CompileError, type CompileOptions } from './src/compile.ts';
|
|
53
|
-
import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
|
|
61
|
+
import { diffLines, diffSkeletons, reportedFigures, sectionFigures, type DiffReport } from './src/diff.ts';
|
|
54
62
|
import { copyAtlasImages } from './src/emit.ts';
|
|
63
|
+
import { DEFAULT_PADDING, DEFAULT_PAGE_SIZE, packAtlas } from './src/atlas.ts';
|
|
55
64
|
import { parseJsonWithPosition } from './src/json-position.ts';
|
|
56
65
|
import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
|
|
57
66
|
import {
|
|
@@ -152,7 +161,7 @@ function repositoryUrl(): string {
|
|
|
152
161
|
* flag": inferring it would turn `--out --json report.json` — a real typo, a
|
|
153
162
|
* missing value — into a silently accepted switch plus a stray positional.
|
|
154
163
|
*/
|
|
155
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again']);
|
|
164
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again', 'pack']);
|
|
156
165
|
|
|
157
166
|
/**
|
|
158
167
|
* The flags a command is allowed to spell more than once.
|
|
@@ -276,6 +285,7 @@ function resolveCut(flags: Record<string, string>): { label: string; opts: Compi
|
|
|
276
285
|
};
|
|
277
286
|
if (flags.manifest !== undefined) opts.manifestPath = resolve(flags.manifest);
|
|
278
287
|
if (flags.images !== undefined) opts.imagesDir = resolve(flags.images);
|
|
288
|
+
if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
|
|
279
289
|
return { label: flags.rig, opts };
|
|
280
290
|
}
|
|
281
291
|
if (flags.cut === undefined) throw new UsageError('give either --cut <name> --cuts <cuts.json>, or --rig/--motion/--out');
|
|
@@ -287,7 +297,13 @@ function resolveCut(flags: Record<string, string>): { label: string; opts: Compi
|
|
|
287
297
|
`unknown cut ${JSON.stringify(flags.cut)} in ${resolve(flags.cuts)}. known: ${Object.keys(table).join(', ') || '(none)'}`,
|
|
288
298
|
);
|
|
289
299
|
}
|
|
290
|
-
|
|
300
|
+
const opts = entryToOptions(dir, flags.cut, entry);
|
|
301
|
+
// `--atlas-in` is not part of the cuts table: a cut names its rig, motion and
|
|
302
|
+
// manifest, and where the pixels are delivered from is a property of the BUILD.
|
|
303
|
+
// Resolved against the working directory, like every other path on the command
|
|
304
|
+
// line, rather than against the table's directory.
|
|
305
|
+
if (flags['atlas-in'] !== undefined) opts.atlasInPath = resolve(flags['atlas-in']);
|
|
306
|
+
return { label: flags.cut, opts };
|
|
291
307
|
}
|
|
292
308
|
|
|
293
309
|
// ---------------------------------------------------------------------------
|
|
@@ -311,15 +327,35 @@ function readProfile(flags: Record<string, string>): ValidateProfile {
|
|
|
311
327
|
return found;
|
|
312
328
|
}
|
|
313
329
|
|
|
314
|
-
|
|
330
|
+
/**
|
|
331
|
+
* An atlas text to gate INSTEAD of the compile's own, with the second, independent
|
|
332
|
+
* emit A18 compares it against.
|
|
333
|
+
*
|
|
334
|
+
* `--pack` is the only caller. A packed build is gated twice on purpose — once as
|
|
335
|
+
* compiled (which is the gate that reads the loose PNGs, so `A06`'s size-vs-file
|
|
336
|
+
* clause still measures the art R5 measures) and once as packed (which is the pair
|
|
337
|
+
* that actually ships). Handing the second pass its texts rather than re-deriving
|
|
338
|
+
* them here keeps `runGate` ignorant of what a pack is.
|
|
339
|
+
*/
|
|
340
|
+
interface AtlasOverride {
|
|
341
|
+
text: string;
|
|
342
|
+
again: string;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function runGate(
|
|
346
|
+
result: CompileResult,
|
|
347
|
+
opts: CompileOptions,
|
|
348
|
+
profile: ValidateProfile,
|
|
349
|
+
atlas?: AtlasOverride,
|
|
350
|
+
): number {
|
|
315
351
|
// The determinism check compares a second, independent compile.
|
|
316
352
|
const again = compile(opts);
|
|
317
353
|
const report = validate({
|
|
318
354
|
skeletonText: result.skeletonText,
|
|
319
|
-
atlasText: result.atlasText,
|
|
355
|
+
atlasText: atlas ? atlas.text : result.atlasText,
|
|
320
356
|
atlasDir: opts.outDir,
|
|
321
357
|
declaredDurations: result.declaredDurations,
|
|
322
|
-
reEmit: { skeletonText: again.skeletonText, atlasText: again.atlasText },
|
|
358
|
+
reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText },
|
|
323
359
|
rig: result.rig,
|
|
324
360
|
profile,
|
|
325
361
|
});
|
|
@@ -352,9 +388,51 @@ function meshFit(m: CompileResult['meshes'][number]): string {
|
|
|
352
388
|
return ` covers ${(m.coverage * 100).toFixed(2)}% of the art, reaching ${m.overshoot?.toFixed(2) ?? '?'}px past it`;
|
|
353
389
|
}
|
|
354
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Read one non-negative integer flag, or its default.
|
|
393
|
+
*
|
|
394
|
+
* A usage error rather than a `NaN` that reaches the packer: `--padding two`
|
|
395
|
+
* would otherwise place every region at NaN and write a blank page, which is a
|
|
396
|
+
* green build and an empty picture.
|
|
397
|
+
*/
|
|
398
|
+
function readIntFlag(flags: Record<string, string>, name: string, fallback: number): number {
|
|
399
|
+
const raw = flags[name];
|
|
400
|
+
if (raw === undefined) return fallback;
|
|
401
|
+
if (!/^\d+$/.test(raw)) throw new UsageError(`--${name} takes a non-negative integer, got ${JSON.stringify(raw)}`);
|
|
402
|
+
return Number(raw);
|
|
403
|
+
}
|
|
404
|
+
|
|
355
405
|
function cmdBuild(flags: Record<string, string>): void {
|
|
356
406
|
const { label, opts } = resolveCut(flags);
|
|
357
407
|
const profile = readProfile(flags);
|
|
408
|
+
const packing = flags.pack !== undefined;
|
|
409
|
+
// Three combinations are refused rather than silently resolved, because in each
|
|
410
|
+
// one the two flags disagree about a single question and there is no answer
|
|
411
|
+
// that is not a guess about which the caller meant.
|
|
412
|
+
if (packing && opts.atlasInPath !== undefined) {
|
|
413
|
+
throw new UsageError(
|
|
414
|
+
'--pack and --atlas-in are opposite directions through the same door: --pack MAKES an atlas out of the ' +
|
|
415
|
+
'loose parts, --atlas-in resolves the parts against one somebody already made. Pick one',
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
if (packing && flags['copy-images'] !== undefined) {
|
|
419
|
+
throw new UsageError(
|
|
420
|
+
'--pack already writes self-contained pages into --out (that is what packing is), and --copy-images copies ' +
|
|
421
|
+
'the loose part PNGs, which a packed atlas does not reference. Drop --copy-images',
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
if (packing && profile === 'spine-html') {
|
|
425
|
+
throw new UsageError(
|
|
426
|
+
"--profile spine-html asserts one part per page (A06's full-page coverage clause), which is rigc's unpacked " +
|
|
427
|
+
'convention and exactly what --pack stops being true. A packed atlas is valid Spine — build it under the ' +
|
|
428
|
+
'default --profile spine',
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (!packing) {
|
|
432
|
+
for (const name of ['page-size', 'padding'] as const) {
|
|
433
|
+
if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
358
436
|
console.log(`rigc build ${label}`);
|
|
359
437
|
// Named explicitly and on their own lines rather than folded into the header
|
|
360
438
|
// above: with two input files, a header that names only one of them (the rig,
|
|
@@ -364,12 +442,26 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
364
442
|
console.log(` .. motion ${opts.motionPath}`);
|
|
365
443
|
const result = compile(opts);
|
|
366
444
|
|
|
445
|
+
if (opts.atlasInPath !== undefined) console.log(` .. atlas-in ${opts.atlasInPath}`);
|
|
367
446
|
console.log(` .. ${result.images.length} part page(s):`);
|
|
368
447
|
for (const img of result.images) {
|
|
369
|
-
|
|
448
|
+
// An imported part says where on the page it came from, because "resolved
|
|
449
|
+
// against a region" is the claim `--atlas-in` makes and a line that only
|
|
450
|
+
// repeated the page filename would look identical for all of them. A page
|
|
451
|
+
// that declares a `scale:` also says so and shows the texels it was read
|
|
452
|
+
// from: the size on the left is the DRAWING's and the rectangle is the
|
|
453
|
+
// pack's, and issue #267 is the report that printed the second as the first.
|
|
454
|
+
const where =
|
|
455
|
+
img.atlas === undefined
|
|
456
|
+
? img.page
|
|
457
|
+
: `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
|
|
458
|
+
(img.atlasScale === undefined
|
|
459
|
+
? ''
|
|
460
|
+
: ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`);
|
|
461
|
+
console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
|
|
370
462
|
}
|
|
371
463
|
for (const d of result.droppedStates) {
|
|
372
|
-
console.log(` DROP ${d.slot}/${d.state}: no PNG at ${d.path} (state not emitted)`);
|
|
464
|
+
console.log(` DROP ${d.slot}/${d.state}: ${d.why ?? `no PNG at ${d.path}`} (state not emitted)`);
|
|
373
465
|
}
|
|
374
466
|
// "The optional slots are optional" is a claim about this code path, so this
|
|
375
467
|
// code path says which ones it left out rather than being silently right.
|
|
@@ -414,6 +506,64 @@ function cmdBuild(flags: Record<string, string>): void {
|
|
|
414
506
|
}
|
|
415
507
|
}
|
|
416
508
|
|
|
509
|
+
// `--pack`: the parts go onto shared pages, which are written here as real
|
|
510
|
+
// PNGs, so `--out` is self-contained by construction. The atlas above stays
|
|
511
|
+
// the one the gate just read — packing changes only the ARRANGEMENT of the
|
|
512
|
+
// bytes, and the sizes in `result.images` are still the ones measured off the
|
|
513
|
+
// loose PNGs (see src/atlas.ts's header).
|
|
514
|
+
if (packing) {
|
|
515
|
+
const packOpts = {
|
|
516
|
+
pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
|
|
517
|
+
padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
|
|
518
|
+
pageStem: 'skeleton',
|
|
519
|
+
};
|
|
520
|
+
const inputs = result.images.map((img) => ({
|
|
521
|
+
region: img.region,
|
|
522
|
+
absPath: img.absPath,
|
|
523
|
+
width: img.width,
|
|
524
|
+
height: img.height,
|
|
525
|
+
}));
|
|
526
|
+
const packed = packAtlas(inputs, packOpts);
|
|
527
|
+
atlasText = packed.atlasText;
|
|
528
|
+
for (const page of packed.pages) {
|
|
529
|
+
page.plate.writePng(join(opts.outDir, page.name));
|
|
530
|
+
console.log(
|
|
531
|
+
` .. pack: ${page.name} ${page.width}x${page.height}, ` +
|
|
532
|
+
`${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
|
|
533
|
+
`${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
for (const place of packed.placements) {
|
|
537
|
+
console.log(
|
|
538
|
+
` .. ${place.region.padEnd(24)} ${place.width}x${place.height} -> ` +
|
|
539
|
+
`${packed.pages[place.page].name} @ ${place.x},${place.y}`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
// The pages are on disk now, so the packed pair can be gated as an artifact
|
|
543
|
+
// rather than trusted as a construction: A17 stats every page, A06 reads its
|
|
544
|
+
// IHDR back, A07 re-reads the text shape, A08 re-joins every attachment onto
|
|
545
|
+
// a region, and A18 compares a second independent compile+pack. Two gates on
|
|
546
|
+
// one build is the cost of shipping a second atlas shape.
|
|
547
|
+
console.log(' .. validate (packed atlas, pages on disk)');
|
|
548
|
+
const packAgain = packAtlas(
|
|
549
|
+
compile(opts).images.map((img) => ({
|
|
550
|
+
region: img.region,
|
|
551
|
+
absPath: img.absPath,
|
|
552
|
+
width: img.width,
|
|
553
|
+
height: img.height,
|
|
554
|
+
})),
|
|
555
|
+
packOpts,
|
|
556
|
+
);
|
|
557
|
+
const packFailures = runGate(result, opts, profile, { text: atlasText, again: packAgain.atlasText });
|
|
558
|
+
if (packFailures > 0) {
|
|
559
|
+
console.error(
|
|
560
|
+
`rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
|
|
561
|
+
`${opts.outDir}, the skeleton/atlas pair was not`,
|
|
562
|
+
);
|
|
563
|
+
process.exit(1);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
417
567
|
writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
|
|
418
568
|
writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
|
|
419
569
|
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
|
|
@@ -1172,6 +1322,38 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
|
1172
1322
|
diffs.push({ skeleton, reference: referencePath, report: diff });
|
|
1173
1323
|
}
|
|
1174
1324
|
|
|
1325
|
+
// Stage 3, optional and behind a flag because the correspondence is an INPUT:
|
|
1326
|
+
// a candidate is entitled to its own bone names, so there is nothing sensible
|
|
1327
|
+
// to default to and a derived mapping would be a guess reported as a
|
|
1328
|
+
// measurement (issue #8). Nothing here gates, and without the flag the report
|
|
1329
|
+
// above is unchanged to the byte.
|
|
1330
|
+
const boneDists: Array<{ skeleton: RungSkeleton; report: BoneDistReport }> = [];
|
|
1331
|
+
if (flags.bones !== undefined) {
|
|
1332
|
+
for (const skeleton of rung.skeletons) {
|
|
1333
|
+
const referencePath = join(exportDir, skeleton.file);
|
|
1334
|
+
if (!existsSync(referencePath)) continue;
|
|
1335
|
+
console.log(` ── bonedist vs ${rung.example}/${skeleton.label} (stage 3) ──`);
|
|
1336
|
+
const boneDist = boneDistance({
|
|
1337
|
+
candidateSkeleton: skeletonPath,
|
|
1338
|
+
candidateAtlas: atlasPath,
|
|
1339
|
+
candidateAtlasDir: dirname(atlasPath),
|
|
1340
|
+
referenceSkeleton: referencePath,
|
|
1341
|
+
referenceAtlas: join(exportDir, skeleton.atlas),
|
|
1342
|
+
referenceAtlasDir: exportDir,
|
|
1343
|
+
bones: flags.bones,
|
|
1344
|
+
// Deliberately NOT `flags.fps`. Inside `bench` that flag already means
|
|
1345
|
+
// "the rate this frame set was recorded at, for a set with no sidecar",
|
|
1346
|
+
// and one flag doing two unrelated things in one command is how a
|
|
1347
|
+
// reader ends up quoting a figure measured at a rate they did not ask
|
|
1348
|
+
// for. A run wanting another sampling rate calls `rigc bonedist`, where
|
|
1349
|
+
// `--fps` has exactly one meaning.
|
|
1350
|
+
});
|
|
1351
|
+
for (const line of boneDistLines(boneDist, { allBones: flags['all-bones'] !== undefined })) console.log(` ${line}`);
|
|
1352
|
+
console.log('');
|
|
1353
|
+
boneDists.push({ skeleton, report: boneDist });
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1175
1357
|
// Third, optional and third for a reason: is it the same MOTION? `diff`
|
|
1176
1358
|
// compares structure, and a reversed easing is the same key count and the same
|
|
1177
1359
|
// curve kind — so a row of this ladder carrying only `validate` and `diff`
|
|
@@ -1197,6 +1379,13 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
|
1197
1379
|
// name-agnostic figure printed beside — issue #21.
|
|
1198
1380
|
const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
|
|
1199
1381
|
if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
|
|
1382
|
+
// A third line, for the same reason the second one is not folded into the
|
|
1383
|
+
// first: the reported measures are unobservable by construction, so they
|
|
1384
|
+
// roll into no mean at all and cannot be shown as one. Each is named with
|
|
1385
|
+
// its own figure — a per-section digest would be the mean this block exists
|
|
1386
|
+
// to refuse.
|
|
1387
|
+
const reported = reportedFigures(d.report);
|
|
1388
|
+
if (reported !== null) console.log(` ${''.padEnd(10)} reported: ${reported}`);
|
|
1200
1389
|
}
|
|
1201
1390
|
if (check) {
|
|
1202
1391
|
// The framing goes first because it is upstream of every MAE below it: a
|
|
@@ -1271,6 +1460,19 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
|
1271
1460
|
console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
|
|
1272
1461
|
console.log(' Without it this report says nothing about whether the ANIMATION is right.');
|
|
1273
1462
|
}
|
|
1463
|
+
if (boneDists.length > 0) {
|
|
1464
|
+
for (const b of boneDists) {
|
|
1465
|
+
const w = b.report.worst;
|
|
1466
|
+
console.log(
|
|
1467
|
+
` ${b.skeleton.label.padEnd(10)} bonedist worst position ${w.position.value.toFixed(6)} skeleton-size(s), ` +
|
|
1468
|
+
`rotation ${w.rotation.value.toFixed(4)}°, scale ${w.scale.value.toFixed(6)}, linear ${w.linear.value.toFixed(6)} ` +
|
|
1469
|
+
`over ${b.report.animations.reduce((n, a) => n + a.compared, 0)} frame(s) × ${b.report.correspondence.pairs} bone pair(s)`,
|
|
1470
|
+
);
|
|
1471
|
+
}
|
|
1472
|
+
} else {
|
|
1473
|
+
console.log(' bonedist not run — pass --bones <correspondence.json | identity> for the stage-3 per-frame');
|
|
1474
|
+
console.log(' bone world-transform distance. It reports and gates nothing.');
|
|
1475
|
+
}
|
|
1274
1476
|
console.log(' Section figures are means of their own measures. There is no rung score:');
|
|
1275
1477
|
console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
|
|
1276
1478
|
|
|
@@ -1298,6 +1500,12 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
|
1298
1500
|
...d.report,
|
|
1299
1501
|
})),
|
|
1300
1502
|
check,
|
|
1503
|
+
// Absent rather than null when the flag was not passed: `bonedist: null`
|
|
1504
|
+
// in a stored record would read as "measured, nothing to report", and
|
|
1505
|
+
// that is the opposite of "not measured".
|
|
1506
|
+
...(boneDists.length === 0
|
|
1507
|
+
? {}
|
|
1508
|
+
: { boneDists: boneDists.map((b) => ({ label: b.skeleton.label, role: b.skeleton.role, ...b.report })) }),
|
|
1301
1509
|
});
|
|
1302
1510
|
}
|
|
1303
1511
|
|
|
@@ -1307,6 +1515,39 @@ function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
|
1307
1515
|
}
|
|
1308
1516
|
}
|
|
1309
1517
|
|
|
1518
|
+
/**
|
|
1519
|
+
* bonedist — the ladder's stage 3, run on its own.
|
|
1520
|
+
*
|
|
1521
|
+
* ⚠️ It reads BOTH skeletons, so it is a finish-line instrument like `bench` and
|
|
1522
|
+
* unlike `check`. Every convention behind every figure is printed above the
|
|
1523
|
+
* tables, and there is no score — see [`src/bonedist.ts`](src/bonedist.ts).
|
|
1524
|
+
*/
|
|
1525
|
+
function cmdBoneDist(flags: Record<string, string>): void {
|
|
1526
|
+
if (flags.candidate === undefined) throw new UsageError('bonedist needs --candidate <dir | skeleton.json>');
|
|
1527
|
+
if (flags.reference === undefined) throw new UsageError('bonedist needs --reference <skeleton.json>');
|
|
1528
|
+
if (flags.bones === undefined) {
|
|
1529
|
+
throw new UsageError(
|
|
1530
|
+
`bonedist needs --bones <correspondence.json | ${IDENTITY_CORRESPONDENCE}> — a candidate is entitled to its own bone ` +
|
|
1531
|
+
'names, so the mapping is an input and never a guess; pass `identity` to state that the two use the same names',
|
|
1532
|
+
);
|
|
1533
|
+
}
|
|
1534
|
+
const candidate = resolveArtifacts(flags.candidate, flags.atlas);
|
|
1535
|
+
const reference = resolveArtifacts(flags.reference, flags['reference-atlas']);
|
|
1536
|
+
const report = boneDistance({
|
|
1537
|
+
candidateSkeleton: candidate.skeletonPath,
|
|
1538
|
+
candidateAtlas: candidate.atlasPath,
|
|
1539
|
+
candidateAtlasDir: dirname(candidate.atlasPath),
|
|
1540
|
+
referenceSkeleton: reference.skeletonPath,
|
|
1541
|
+
referenceAtlas: reference.atlasPath,
|
|
1542
|
+
referenceAtlasDir: dirname(reference.atlasPath),
|
|
1543
|
+
bones: flags.bones,
|
|
1544
|
+
...(flags.fps === undefined ? {} : { fps: Number(flags.fps) }),
|
|
1545
|
+
});
|
|
1546
|
+
console.log('rigc bonedist — per-frame bone world-transform distance (the ladder\'s stage 3)');
|
|
1547
|
+
for (const line of boneDistLines(report, { allBones: flags['all-bones'] !== undefined })) console.log(line);
|
|
1548
|
+
if (flags.json !== undefined) writeJson(flags.json, report);
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1310
1551
|
function cmdExplain(flags: Record<string, string>): void {
|
|
1311
1552
|
const { label, opts } = resolveCut(flags);
|
|
1312
1553
|
console.log(`rigc explain ${label}`);
|
|
@@ -1555,12 +1796,29 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1555
1796
|
'copy-images':
|
|
1556
1797
|
'also copy every referenced page PNG into --out and rewrite the atlas to the copies, so the directory is ' +
|
|
1557
1798
|
'self-contained enough to zip or commit on its own (default: page paths still point at the source art)',
|
|
1799
|
+
pack: 'arrange every part PNG onto shared atlas page(s) written into --out as real PNGs, instead of one page ' +
|
|
1800
|
+
'per part. Lossless: every region is a byte-for-byte copy and nothing is resampled, trimmed or rotated ' +
|
|
1801
|
+
'(default: one part, one page, pointing at the source art)',
|
|
1802
|
+
'page-size': `largest page edge, --pack only (default ${DEFAULT_PAGE_SIZE}); pages are powers of two and the ` +
|
|
1803
|
+
'one written is the smallest that holds the pack, spilling to more pages only when the set will not fit',
|
|
1804
|
+
padding: `gutter each region reserves on every side, --pack only (default ${DEFAULT_PADDING}); it is filled by ` +
|
|
1805
|
+
"extending the region's own edge pixels outwards, which is what stops a neighbour bleeding in",
|
|
1806
|
+
'atlas-in':
|
|
1807
|
+
'resolve every part against the regions of this pre-packed .atlas instead of against loose PNGs — region ' +
|
|
1808
|
+
'geometry (bounds/offsets/rotate) is read from the file and the atlas is re-emitted into --out, re-anchored',
|
|
1558
1809
|
cut: 'look up a named cut in --cuts <cuts.json>, instead of --rig/--motion/--out',
|
|
1559
1810
|
cuts: 'the cuts.json --cut names',
|
|
1560
1811
|
profile:
|
|
1561
1812
|
'which rulebook to check against (default: spine) — spine = valid Spine 4.3 that any runtime plays ' +
|
|
1562
1813
|
"correctly; spine-html = also this project's renderer/archetype policy",
|
|
1563
1814
|
atlas: "the candidate's atlas, when it is not beside the skeleton",
|
|
1815
|
+
reference: 'the reference skeleton to pose beside the candidate — a directory or a skeleton.json path',
|
|
1816
|
+
'reference-atlas': "the reference's atlas, when it is not beside the reference skeleton",
|
|
1817
|
+
bones: `a bone correspondence — { "spec": "${BONEDIST_SPEC}", "bones": { "<candidate bone>": "<reference bone>" }, ` +
|
|
1818
|
+
'"animations"?: { … } } — or `identity` to state that the two skeletons use the same names. An INPUT, never ' +
|
|
1819
|
+
'derived: a candidate is entitled to its own vocabulary, so a mapping worked out here would be a guess reported ' +
|
|
1820
|
+
'as a measurement',
|
|
1821
|
+
'all-bones': 'print every bone pair, not just the worst by position',
|
|
1564
1822
|
'texture-from':
|
|
1565
1823
|
"also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
|
|
1566
1824
|
'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
|
|
@@ -1600,7 +1858,13 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1600
1858
|
cuts: '<path>',
|
|
1601
1859
|
profile: 'spine|spine-html',
|
|
1602
1860
|
atlas: '<path>',
|
|
1861
|
+
'atlas-in': '<file.atlas>',
|
|
1862
|
+
'page-size': '<px>',
|
|
1863
|
+
padding: '<px>',
|
|
1603
1864
|
'texture-from': '<path>',
|
|
1865
|
+
reference: '<dir|skeleton.json>',
|
|
1866
|
+
'reference-atlas': '<path>',
|
|
1867
|
+
bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
|
|
1604
1868
|
candidate: '<dir|skeleton.json>',
|
|
1605
1869
|
frames: '<dir>',
|
|
1606
1870
|
fps: '<n>',
|
|
@@ -1646,9 +1910,25 @@ const COMMANDS: CommandDoc[] = [
|
|
|
1646
1910
|
name: 'build',
|
|
1647
1911
|
usage: [
|
|
1648
1912
|
'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images]',
|
|
1913
|
+
`rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] (parts onto shared pages, written into --out)`,
|
|
1914
|
+
'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
|
|
1649
1915
|
'rigc build --cut <name> --cuts <cuts.json>',
|
|
1650
1916
|
],
|
|
1651
|
-
flags: [
|
|
1917
|
+
flags: [
|
|
1918
|
+
'rig',
|
|
1919
|
+
'motion',
|
|
1920
|
+
'out',
|
|
1921
|
+
'manifest',
|
|
1922
|
+
'images',
|
|
1923
|
+
'copy-images',
|
|
1924
|
+
'pack',
|
|
1925
|
+
'page-size',
|
|
1926
|
+
'padding',
|
|
1927
|
+
'atlas-in',
|
|
1928
|
+
'cut',
|
|
1929
|
+
'cuts',
|
|
1930
|
+
'profile',
|
|
1931
|
+
],
|
|
1652
1932
|
},
|
|
1653
1933
|
{
|
|
1654
1934
|
name: 'explain',
|
|
@@ -1676,7 +1956,25 @@ const COMMANDS: CommandDoc[] = [
|
|
|
1676
1956
|
{
|
|
1677
1957
|
name: 'bench',
|
|
1678
1958
|
usage: [`rigc bench <${RUNG_IDS.join(' | ')}> --candidate <dir | skeleton.json> [--frames <dir>] [flags]`],
|
|
1679
|
-
flags: ['candidate', 'atlas', 'frames', 'profile', 'all-frames', 'json'],
|
|
1959
|
+
flags: ['candidate', 'atlas', 'frames', 'profile', 'bones', 'all-frames', 'all-bones', 'json'],
|
|
1960
|
+
overrides: {
|
|
1961
|
+
bones: {
|
|
1962
|
+
meaning:
|
|
1963
|
+
'also run the stage-3 per-frame bone world-transform distance against each of the rung\'s reference ' +
|
|
1964
|
+
`skeletons, with this correspondence (or \`identity\`), at ${PROTOCOL_FPS} fps. Reports; gates nothing — ` +
|
|
1965
|
+
'for another sampling rate call `rigc bonedist` directly, where --fps means only that',
|
|
1966
|
+
},
|
|
1967
|
+
},
|
|
1968
|
+
},
|
|
1969
|
+
{
|
|
1970
|
+
name: 'bonedist',
|
|
1971
|
+
usage: [
|
|
1972
|
+
`rigc bonedist --candidate <dir | skeleton.json> --reference <dir | skeleton.json> --bones <path | ${IDENTITY_CORRESPONDENCE}> [--fps ${PROTOCOL_FPS}] [--all-bones] [--json <out>]`,
|
|
1973
|
+
],
|
|
1974
|
+
flags: ['candidate', 'atlas', 'reference', 'reference-atlas', 'bones', 'fps', 'all-bones', 'json'],
|
|
1975
|
+
overrides: {
|
|
1976
|
+
fps: { meaning: `the rate both skeletons are sampled at, from t=0 over their own durations (default ${PROTOCOL_FPS})` },
|
|
1977
|
+
},
|
|
1680
1978
|
},
|
|
1681
1979
|
{
|
|
1682
1980
|
name: 'render',
|
|
@@ -1843,6 +2141,7 @@ try {
|
|
|
1843
2141
|
else if (command === 'diff') cmdDiff(flags, positional);
|
|
1844
2142
|
else if (command === 'check') cmdCheck(flags);
|
|
1845
2143
|
else if (command === 'bench') cmdBench(flags, positional);
|
|
2144
|
+
else if (command === 'bonedist') cmdBoneDist(flags);
|
|
1846
2145
|
else if (command === 'render') cmdRender(flags);
|
|
1847
2146
|
else if (command === 'preview') cmdPreview(flags);
|
|
1848
2147
|
else if (command === 'pose') cmdPose(flags);
|
|
@@ -1866,6 +2165,10 @@ try {
|
|
|
1866
2165
|
console.error(`rigc check error: ${err.message}`);
|
|
1867
2166
|
process.exit(1);
|
|
1868
2167
|
}
|
|
2168
|
+
if (err instanceof BoneDistError) {
|
|
2169
|
+
console.error(`rigc bonedist error: ${err.message}`);
|
|
2170
|
+
process.exit(1);
|
|
2171
|
+
}
|
|
1869
2172
|
// Like a usage error in kind — a missing directory, an unreadable frame — but
|
|
1870
2173
|
// its messages name a path and a reason, and reprinting the whole usage under
|
|
1871
2174
|
// them buries that.
|