rig-c 0.0.0-stage → 2.21.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1191 -0
- package/src/meshquality.ts +2051 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1444 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
|
@@ -0,0 +1,820 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bodies of the commands that run through spine-core — `build`,
|
|
3
|
+
* `validate` and `bench` (the gate is the round trip), `bonedist` (it poses
|
|
4
|
+
* both skeletons through the runtime by design), `preview` and `vote` (the
|
|
5
|
+
* page names its atlas pages through the runtime's reader, and its gate line
|
|
6
|
+
* is the round trip's) — moved here unchanged from `cli.ts` (issue #1052, step
|
|
7
|
+
* 4e of #380), with the helpers only they call. Only `cli.ts` imports this
|
|
8
|
+
* module (`SPINE_COMMAND_RUNS`); an entry that does not links nothing of the
|
|
9
|
+
* runtime, and refuses these commands naming what each needs it for.
|
|
10
|
+
*/
|
|
11
|
+
import { type BallotCandidateInput, type BallotInput, buildBallot, ledgerLineText, MAX_CANDIDATES, MIN_CANDIDATES, parseLedger, readBallotManifest, resultFilename, TIE, verifyResult, VOTE_RULES } from '../ballot.ts';
|
|
12
|
+
import { boneDistance, boneDistLines, type BoneDistReport } from '../bonedist.ts';
|
|
13
|
+
import { IDENTITY_CORRESPONDENCE } from '../correspondence.ts';
|
|
14
|
+
import { checkLines, type CheckReport } from '../check.ts';
|
|
15
|
+
import { compile } from '../compile.ts';
|
|
16
|
+
import { diffLines, type DiffReport, diffSkeletons, reportedFigures, sectionFigures } from '../diff.ts';
|
|
17
|
+
import { findRung, RUNG_IDS, type RungSkeleton } from '../ladder.ts';
|
|
18
|
+
import { buildPreview, buildPreviewPanes, PLAYER_LINE, type PreviewGate, type PreviewInput, type PreviewPage } from '../preview.ts';
|
|
19
|
+
import { atlasPageNames } from '../render.ts';
|
|
20
|
+
import { BoneDistError } from '../bonedist.ts';
|
|
21
|
+
import { assertionCountForProfile, CLI_DEFAULT_PROFILE, reportLines, validate, type ValidateProfile } from '../validate.ts';
|
|
22
|
+
import { type CliRefusal, type CommandRun, DEFAULT_BALLOT, PACKAGE_ROOT, DEFAULT_LEDGER, parseJsonNamed, readAnimationFlag, readJsonFile, readPackageMeta, readSkeletonText, readVersion, resolveBuild, resolveCut, resolveViewable, runCheck, spinePairOf, type BuildGate, runBuild, readProfile, STAGELESS_FRAMING, UsageError, writeJson, modelTextBeside } from './shared.ts';
|
|
23
|
+
import { cmdRepack } from './repack.ts';
|
|
24
|
+
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { dirname, join, resolve } from 'node:path';
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
function repositoryUrl(): string {
|
|
29
|
+
const repo = readPackageMeta()?.repository;
|
|
30
|
+
const url = typeof repo === 'string' ? repo : repo?.url;
|
|
31
|
+
return (url ?? 'https://github.com/firejune/rigc').replace(/^git\+/, '').replace(/\.git$/, '');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
// commands
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* `cli.ts build`'s gate (issue #1060, `BuildGate`): the round trip through
|
|
40
|
+
* spine-core and every named assertion — the gate `build` has always run,
|
|
41
|
+
* with the arguments it has always been handed, and the same lines.
|
|
42
|
+
*/
|
|
43
|
+
const ROUND_TRIP_GATE: BuildGate = {
|
|
44
|
+
supplier: 'round-trip',
|
|
45
|
+
heading: (profile) => ` .. validate (spine-core round trip + machine assertions, profile ${profile})`,
|
|
46
|
+
run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
|
|
47
|
+
const report = validate({
|
|
48
|
+
skeletonText: result.skeletonText,
|
|
49
|
+
atlasText,
|
|
50
|
+
atlasDir,
|
|
51
|
+
declaredDurations: result.declaredDurations,
|
|
52
|
+
modelText,
|
|
53
|
+
reEmit,
|
|
54
|
+
rig: result.rig,
|
|
55
|
+
profile,
|
|
56
|
+
});
|
|
57
|
+
for (const line of reportLines(report)) console.log(line);
|
|
58
|
+
console.log(
|
|
59
|
+
` .. ${Object.entries(report.stats)
|
|
60
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
61
|
+
.join(' ')}`,
|
|
62
|
+
);
|
|
63
|
+
return { report, here: null };
|
|
64
|
+
},
|
|
65
|
+
look: (outDir) => `rigc: look at it: rigc preview --candidate ${outDir}`,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function cmdBuild(flags: Record<string, string>): void {
|
|
69
|
+
runBuild(flags, ROUND_TRIP_GATE);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function cmdValidate(flags: Record<string, string>, positional: string[]): void {
|
|
73
|
+
// A bare directory validates what is on disk. Naming the cut as well lets the
|
|
74
|
+
// gate re-derive the declared durations and the structural expectations, which
|
|
75
|
+
// a directory alone cannot supply — and the report says which it had.
|
|
76
|
+
const named = flags.cut !== undefined || flags.rig !== undefined;
|
|
77
|
+
const profile = readProfile(flags);
|
|
78
|
+
const derivedOpts = named ? resolveCut(flags).opts : null;
|
|
79
|
+
// Both files there before the first line, from the one statement of a build (issue #1046): a directory without them
|
|
80
|
+
// read the skeleton unguarded and died on an ENOENT and a stack.
|
|
81
|
+
const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas));
|
|
82
|
+
console.log(`rigc validate ${skeletonPath}`);
|
|
83
|
+
console.log(` .. atlas ${atlasPath}`);
|
|
84
|
+
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
85
|
+
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
86
|
+
const derived = derivedOpts ? compile(derivedOpts) : null;
|
|
87
|
+
|
|
88
|
+
const modelText = modelTextBeside(skeletonPath, skeletonText);
|
|
89
|
+
const report = validate({
|
|
90
|
+
skeletonText,
|
|
91
|
+
atlasText,
|
|
92
|
+
atlasDir: dirname(atlasPath),
|
|
93
|
+
declaredDurations: derived?.declaredDurations,
|
|
94
|
+
rig: derived?.rig,
|
|
95
|
+
...(modelText === undefined ? {} : { modelText }),
|
|
96
|
+
profile,
|
|
97
|
+
});
|
|
98
|
+
for (const line of reportLines(report)) console.log(line);
|
|
99
|
+
if (report.failures.length > 0) {
|
|
100
|
+
console.error(`rigc: ${report.failures.length} assertion(s) failed`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
console.log('rigc: green');
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Does this skeleton declare a setup stage — a numeric width AND height?
|
|
108
|
+
*
|
|
109
|
+
* Read off the file's header for `preview`, which never loads one; `render`
|
|
110
|
+
* reads the same statement off its candidate (`SkeletonFacts.declaresStage`:
|
|
111
|
+
* the header again on a rigc build the core poses, the loaded `SkeletonData`
|
|
112
|
+
* on an export). Both are the same two fields, because
|
|
113
|
+
* `SkeletonJson` copies them across unconditionally (`SkeletonJson.js:70-73`),
|
|
114
|
+
* so a header that omits them leaves `undefined` on a field typed `number`.
|
|
115
|
+
*/
|
|
116
|
+
function declaresSetupStage(header: { width?: unknown; height?: unknown }): boolean {
|
|
117
|
+
return typeof header.width === 'number' && typeof header.height === 'number';
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** The `skeleton` block of a skeleton file's text, or an empty one where it has none. */
|
|
121
|
+
function skeletonHeaderOf(skeletonText: string): { width?: unknown; height?: unknown } {
|
|
122
|
+
const root = JSON.parse(skeletonText) as { skeleton?: { width?: unknown; height?: unknown } };
|
|
123
|
+
return root.skeleton ?? {};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** The animation names an emitted skeleton carries, in the order it lists them. */
|
|
127
|
+
function skeletonAnimationNames(skeletonText: string, path: string): string[] {
|
|
128
|
+
const parsed = parseJsonNamed(skeletonText, path);
|
|
129
|
+
if (typeof parsed !== 'object' || parsed === null) throw new UsageError(`${path} is not a skeleton object`);
|
|
130
|
+
const animations = (parsed as { animations?: unknown }).animations;
|
|
131
|
+
if (animations === undefined) return [];
|
|
132
|
+
if (typeof animations !== 'object' || animations === null || Array.isArray(animations)) {
|
|
133
|
+
throw new UsageError(`${path} has an "animations" field that is not an object`);
|
|
134
|
+
}
|
|
135
|
+
return Object.keys(animations);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The gate's reading of one candidate, taken the way `rigc validate <dir>`
|
|
140
|
+
* takes it (issue #837).
|
|
141
|
+
*
|
|
142
|
+
* ⭐ The same call `cmdValidate` makes on a bare directory: the two texts, the
|
|
143
|
+
* atlas's own directory, the default profile, the build's model document
|
|
144
|
+
* where the one beside the skeleton is its own (`modelTextBeside`, issue #907
|
|
145
|
+
* — the stage A14 and A19 read), and nothing a directory cannot supply — no
|
|
146
|
+
* rig spec, no declared durations, no second compile. So `A09` and
|
|
147
|
+
* `A18` report SKIP here exactly as they do there, and the line is the line
|
|
148
|
+
* that command prints for these files, not the one `build` printed for the
|
|
149
|
+
* compile that wrote them. Measured on every run, because the page must not
|
|
150
|
+
* carry a figure this run did not measure.
|
|
151
|
+
*/
|
|
152
|
+
function previewGate(skeletonPath: string, skeletonText: string, atlasText: string, atlasDir: string): PreviewGate {
|
|
153
|
+
const modelText = modelTextBeside(skeletonPath, skeletonText);
|
|
154
|
+
const lines = reportLines(validate({ skeletonText, atlasText, atlasDir, ...(modelText === undefined ? {} : { modelText }), profile: CLI_DEFAULT_PROFILE }));
|
|
155
|
+
const refusal = lines.find((line) => line.startsWith(' FAIL '));
|
|
156
|
+
return {
|
|
157
|
+
// `reportLines` ends on the summary by construction; the gutter is the
|
|
158
|
+
// report's layout, not part of what the gate said.
|
|
159
|
+
summary: lines[lines.length - 1].replace(/^ {2}\.\. {4}/, ''),
|
|
160
|
+
refusal: refusal === undefined ? null : refusal.trimStart(),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* preview — the artifact playing in Esoteric's own web player, as one file.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ Nothing is rasterised here and nothing is decoded. The pages go into the
|
|
168
|
+
* page as the bytes they are on disk, so a preview works for any PNG a BROWSER
|
|
169
|
+
* can draw rather than for the ones our own decoder reads — which is the right
|
|
170
|
+
* direction for the command whose whole job is "just show me".
|
|
171
|
+
*/
|
|
172
|
+
export function cmdPreview(flags: Record<string, string>, candidates: string[]): void {
|
|
173
|
+
// Refused rather than ignored: a preview asked to hide `head` that plays the
|
|
174
|
+
// whole rig is a picture that answers a question it was not asked (issue #835).
|
|
175
|
+
for (const flag of ['slot', 'hide'] as const) {
|
|
176
|
+
if (flags[flag] !== undefined) {
|
|
177
|
+
throw new UsageError(
|
|
178
|
+
`preview takes no --${flag}: the Spine Web Player draws what the skeleton draws. A subset of the slots is ` +
|
|
179
|
+
`\`rigc render --${flag} ${flags[flag]}\`, on the whole rig's grid`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const several = candidates.length > 1;
|
|
184
|
+
// `--atlas` names ONE atlas, and with several skeletons there is no
|
|
185
|
+
// unambiguous thing it could mean — the refusal `vote` makes, for the reason
|
|
186
|
+
// it makes it.
|
|
187
|
+
if (several && flags.atlas !== undefined) {
|
|
188
|
+
throw new UsageError(
|
|
189
|
+
`--atlas names one atlas and ${candidates.length} --candidate were given; each candidate's atlas has to sit ` +
|
|
190
|
+
'beside its skeleton, which is what `build --out` leaves behind',
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
const found = several
|
|
194
|
+
? candidates.map((target) => {
|
|
195
|
+
const { skeletonPath, atlasPath, atlasDir } = spinePairOf(resolveBuild(target, undefined));
|
|
196
|
+
return { target, skeletonPath, atlasPath, atlasDir };
|
|
197
|
+
})
|
|
198
|
+
: [{ target: flags.candidate, ...resolveViewable(flags) }];
|
|
199
|
+
// ⚠️ By the FILE each one resolves to, not by the text typed: `build/` and
|
|
200
|
+
// `build/skeleton.json` are two spellings of one candidate, and so are
|
|
201
|
+
// `/tmp/x` and `/private/tmp/x` on a machine where one is a link to the other
|
|
202
|
+
// — `resolve` alone left that pair unrefused, measured on macOS. A page
|
|
203
|
+
// showing one skeleton twice is two panes that look like a comparison of
|
|
204
|
+
// nothing. (`vote` accepts a repeat today, exit 0; that is its own card.)
|
|
205
|
+
const identities = found.map((f) => realpathSync(f.skeletonPath));
|
|
206
|
+
for (let i = 1; i < found.length; i++) {
|
|
207
|
+
const first = identities.indexOf(identities[i]);
|
|
208
|
+
if (first < i) {
|
|
209
|
+
throw new UsageError(
|
|
210
|
+
`--candidate ${JSON.stringify(found[i].target)} is ${identities[i]}, which --candidate ` +
|
|
211
|
+
`${JSON.stringify(found[first].target)} already names (candidates ${first + 1} and ${i + 1}); a pane per ` +
|
|
212
|
+
'candidate would show the same skeleton twice',
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Every candidate's texts and animation are read before a line is printed,
|
|
218
|
+
// so a refusal about any of them comes before the report, as it always has.
|
|
219
|
+
const loaded = found.map((f, i) => {
|
|
220
|
+
const skeletonText = readFileSync(f.skeletonPath, 'utf8');
|
|
221
|
+
const atlasText = readFileSync(f.atlasPath, 'utf8');
|
|
222
|
+
const animations = skeletonAnimationNames(skeletonText, f.skeletonPath);
|
|
223
|
+
let chosen: string | undefined;
|
|
224
|
+
try {
|
|
225
|
+
chosen = readAnimationFlag(flags, animations);
|
|
226
|
+
} catch (err) {
|
|
227
|
+
// With several candidates the refusal has to say WHICH one lacks it.
|
|
228
|
+
if (several && err instanceof UsageError) {
|
|
229
|
+
throw new UsageError(`candidate ${i + 1} (${f.skeletonPath}): ${err.message}`);
|
|
230
|
+
}
|
|
231
|
+
throw err;
|
|
232
|
+
}
|
|
233
|
+
return { ...f, skeletonText, atlasText, animations, chosen };
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// A directory for --out is taken as "put the default name in here", because
|
|
237
|
+
// `--out render/` is what the sibling command means by the same flag and a
|
|
238
|
+
// preview written OVER a directory is not a recoverable mistake.
|
|
239
|
+
const target = resolve(flags.out ?? 'preview.html');
|
|
240
|
+
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, 'preview.html') : target;
|
|
241
|
+
const version = readVersion();
|
|
242
|
+
|
|
243
|
+
console.log('rigc preview');
|
|
244
|
+
const inputs: PreviewInput[] = loaded.map((candidate, i) => {
|
|
245
|
+
const { skeletonPath, atlasPath, atlasDir, skeletonText, atlasText, animations, chosen } = candidate;
|
|
246
|
+
if (several) console.log(` .. pane ${i + 1} of ${loaded.length}`);
|
|
247
|
+
console.log(` .. skeleton ${skeletonPath}`);
|
|
248
|
+
console.log(` .. atlas ${atlasPath}`);
|
|
249
|
+
const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
|
|
250
|
+
const path = join(atlasDir, name);
|
|
251
|
+
if (!existsSync(path)) {
|
|
252
|
+
throw new UsageError(
|
|
253
|
+
`the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
|
|
254
|
+
'a page a preview cannot embed is a page the player could not have loaded either',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return { name, bytes: readFileSync(path) };
|
|
258
|
+
});
|
|
259
|
+
for (const page of pages) {
|
|
260
|
+
console.log(` .. page ${page.name.padEnd(28)} ${(page.bytes.length / 1024).toFixed(1)} KiB`);
|
|
261
|
+
}
|
|
262
|
+
if (!declaresSetupStage(skeletonHeaderOf(skeletonText))) console.log(` .. ${STAGELESS_FRAMING.preview}`);
|
|
263
|
+
const gate = previewGate(skeletonPath, skeletonText, atlasText, atlasDir);
|
|
264
|
+
console.log(` .. gate ${gate.summary}`);
|
|
265
|
+
if (gate.refusal !== null) {
|
|
266
|
+
console.log(
|
|
267
|
+
` .. gate refused — ${gate.refusal}. Previewed anyway: looking at a red build is what preview is ` +
|
|
268
|
+
'for, and the page header says the same',
|
|
269
|
+
);
|
|
270
|
+
}
|
|
271
|
+
return {
|
|
272
|
+
skeletonText,
|
|
273
|
+
atlasText,
|
|
274
|
+
pages,
|
|
275
|
+
animation: chosen ?? animations[0] ?? null,
|
|
276
|
+
animations,
|
|
277
|
+
label: skeletonPath,
|
|
278
|
+
version,
|
|
279
|
+
gate,
|
|
280
|
+
};
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
const html = several ? buildPreviewPanes(inputs, version) : buildPreview(inputs[0]);
|
|
284
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
285
|
+
writeFileSync(out, html);
|
|
286
|
+
const pageCount = inputs.reduce((n, input) => n + input.pages.length, 0);
|
|
287
|
+
console.log(
|
|
288
|
+
several
|
|
289
|
+
? ` .. embedded ${pageCount} page(s) + ${inputs.length} skeletons and atlases as data URIs, one pane each; ` +
|
|
290
|
+
`the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`
|
|
291
|
+
: ` .. embedded ${pageCount} page(s) + the skeleton and atlas as data URIs; ` +
|
|
292
|
+
`the player itself loads from unpkg (@${PLAYER_LINE}), so the first open needs a network`,
|
|
293
|
+
);
|
|
294
|
+
console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/** Load one candidate off disk in the shape a ballot needs. */
|
|
298
|
+
function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
|
|
299
|
+
const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(target, undefined));
|
|
300
|
+
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
301
|
+
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
302
|
+
const atlasDir = dirname(atlasPath);
|
|
303
|
+
const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
|
|
304
|
+
const path = join(atlasDir, name);
|
|
305
|
+
if (!existsSync(path)) {
|
|
306
|
+
throw new UsageError(
|
|
307
|
+
`the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
|
|
308
|
+
'a page a ballot cannot embed is a page the player could not have loaded either',
|
|
309
|
+
);
|
|
310
|
+
}
|
|
311
|
+
return { name, bytes: readFileSync(path) };
|
|
312
|
+
});
|
|
313
|
+
return {
|
|
314
|
+
candidate: { source: skeletonPath, skeletonText, atlasText, pages },
|
|
315
|
+
animations: skeletonAnimationNames(skeletonText, skeletonPath),
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* The one animation every candidate plays.
|
|
321
|
+
*
|
|
322
|
+
* ⚠️ Refused rather than resolved per candidate. Two panes running two
|
|
323
|
+
* different animations look like a comparison and are not one, and a voter has
|
|
324
|
+
* no way to see that it happened — the labels are `A` and `B`, which is the
|
|
325
|
+
* whole point, so nothing on the screen would say so.
|
|
326
|
+
*/
|
|
327
|
+
function commonAnimation(
|
|
328
|
+
flags: Record<string, string>,
|
|
329
|
+
loaded: { animations: string[] }[],
|
|
330
|
+
): string | null {
|
|
331
|
+
const asked = flags.animation;
|
|
332
|
+
if (asked === undefined) {
|
|
333
|
+
const first = loaded[0].animations[0];
|
|
334
|
+
if (first === undefined) {
|
|
335
|
+
const withAny = loaded.findIndex((l) => l.animations.length > 0);
|
|
336
|
+
if (withAny !== -1) {
|
|
337
|
+
throw new UsageError(
|
|
338
|
+
`candidate ${withAny + 1} has animations [${loaded[withAny].animations.join(', ')}] and candidate 1 has none — ` +
|
|
339
|
+
'a ballot plays one animation in every pane, so there is nothing to compare here',
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
const missing = loaded.findIndex((l) => !l.animations.includes(first));
|
|
345
|
+
if (missing !== -1) {
|
|
346
|
+
throw new UsageError(
|
|
347
|
+
`the default animation is candidate 1's first, ${JSON.stringify(first)}, and candidate ${missing + 1} does not ` +
|
|
348
|
+
`have it (it has [${loaded[missing].animations.join(', ') || 'none'}]); name one they share with --animation`,
|
|
349
|
+
);
|
|
350
|
+
}
|
|
351
|
+
return first;
|
|
352
|
+
}
|
|
353
|
+
const missing = loaded.findIndex((l) => !l.animations.includes(asked));
|
|
354
|
+
if (missing !== -1) {
|
|
355
|
+
throw new UsageError(
|
|
356
|
+
`no animation ${JSON.stringify(asked)} in candidate ${missing + 1}; it has ` +
|
|
357
|
+
`[${loaded[missing].animations.join(', ') || 'none'}]`,
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
return asked;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** vote (ballot mode) — write the page a human opens. */
|
|
364
|
+
function cmdVoteBallot(flags: Record<string, string>, candidates: string[]): void {
|
|
365
|
+
if (candidates.length < MIN_CANDIDATES) {
|
|
366
|
+
throw new UsageError(
|
|
367
|
+
`a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} --candidate <dir | skeleton.json>, and ${candidates.length} ` +
|
|
368
|
+
'was given — one candidate on its own is `rigc preview`',
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
if (candidates.length > MAX_CANDIDATES) {
|
|
372
|
+
throw new UsageError(
|
|
373
|
+
`${candidates.length} candidates were given and a ballot holds at most ${MAX_CANDIDATES} — they go side by side ` +
|
|
374
|
+
'on one screen, and a comparison that needs scrolling is not a comparison',
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
// `--atlas` names ONE atlas and there are several skeletons here, so there is
|
|
378
|
+
// no unambiguous thing it could mean. Each candidate's atlas has to sit beside
|
|
379
|
+
// its skeleton, which is what `build --out` leaves behind.
|
|
380
|
+
if (flags.atlas !== undefined) {
|
|
381
|
+
throw new UsageError(
|
|
382
|
+
'--atlas names one atlas and a ballot has several candidates; each one\'s atlas has to sit beside its skeleton',
|
|
383
|
+
);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const loaded = candidates.map((target) => loadBallotCandidate(target));
|
|
387
|
+
const animation = commonAnimation(flags, loaded);
|
|
388
|
+
|
|
389
|
+
const target = resolve(flags.out ?? DEFAULT_BALLOT);
|
|
390
|
+
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_BALLOT) : target;
|
|
391
|
+
|
|
392
|
+
const input: BallotInput = {
|
|
393
|
+
candidates: loaded.map((l) => l.candidate),
|
|
394
|
+
animation,
|
|
395
|
+
version: readVersion(),
|
|
396
|
+
};
|
|
397
|
+
const { html, manifest } = buildBallot(input);
|
|
398
|
+
|
|
399
|
+
console.log('rigc vote');
|
|
400
|
+
console.log(` .. ballot ${manifest.ballot}`);
|
|
401
|
+
console.log(` .. animation ${animation === null ? '(none — the setup pose)' : animation}`);
|
|
402
|
+
for (let i = 0; i < manifest.candidates.length; i++) {
|
|
403
|
+
const entry = manifest.candidates[i];
|
|
404
|
+
const bytes = loaded[i].candidate.pages.reduce((n, p) => n + p.bytes.length, 0);
|
|
405
|
+
console.log(
|
|
406
|
+
` .. ${entry.label} ${entry.digest.slice(0, 'sha256:'.length + 12)}… ` +
|
|
407
|
+
`${entry.pages.length} page(s), ${(bytes / 1024).toFixed(1)} KiB <- ${entry.source}`,
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
411
|
+
writeFileSync(out, html);
|
|
412
|
+
console.log(
|
|
413
|
+
` .. the page shows ${manifest.candidates.map((c) => c.label).join('/')} and nothing else — the paths above are ` +
|
|
414
|
+
'in its manifest, never on the screen',
|
|
415
|
+
);
|
|
416
|
+
console.log(
|
|
417
|
+
` .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from ` +
|
|
418
|
+
`unpkg (@${PLAYER_LINE}), so the first open needs a network`,
|
|
419
|
+
);
|
|
420
|
+
console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
|
|
421
|
+
console.log(
|
|
422
|
+
`rigc: then record the saved vote with rigc vote --record ${resultFilename(manifest.ballot)} --ballot ${out}`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** vote (record mode) — check one saved vote and append it to the ledger. */
|
|
427
|
+
function cmdVoteRecord(flags: Record<string, string>): void {
|
|
428
|
+
for (const key of ['candidate', 'out'] as const) {
|
|
429
|
+
if (flags[key] !== undefined) {
|
|
430
|
+
throw new UsageError(`--record and --${key} are the two halves of this command; run them one at a time`);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
const resultPath = resolve(flags.record);
|
|
434
|
+
const ballotPath = resolve(flags.ballot ?? DEFAULT_BALLOT);
|
|
435
|
+
const ledgerPath = resolve(flags.ledger ?? DEFAULT_LEDGER);
|
|
436
|
+
for (const [what, path] of [
|
|
437
|
+
['result', resultPath],
|
|
438
|
+
['ballot', ballotPath],
|
|
439
|
+
] as const) {
|
|
440
|
+
if (!existsSync(path)) {
|
|
441
|
+
throw new UsageError(
|
|
442
|
+
`no ${what} file at ${path}` + (what === 'ballot' ? ' — name the page this vote came from with --ballot' : ''),
|
|
443
|
+
);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
console.log('rigc vote --record');
|
|
448
|
+
console.log(` .. result ${resultPath}`);
|
|
449
|
+
console.log(` .. ballot ${ballotPath}`);
|
|
450
|
+
console.log(` .. ledger ${ledgerPath}`);
|
|
451
|
+
|
|
452
|
+
const manifest = readBallotManifest(readFileSync(ballotPath, 'utf8'), ballotPath);
|
|
453
|
+
const result = readJsonFile(resultPath);
|
|
454
|
+
const existing = existsSync(ledgerPath) ? parseLedger(readFileSync(ledgerPath, 'utf8'), ledgerPath) : [];
|
|
455
|
+
const attempts = existing.filter((l) => l.ballot === manifest.ballot).length;
|
|
456
|
+
const again = flags.again !== undefined;
|
|
457
|
+
|
|
458
|
+
const { refusals, line } = verifyResult(manifest, result, { attempts, again });
|
|
459
|
+
if (line === null) {
|
|
460
|
+
for (const refusal of refusals) console.error(` FAIL ${refusal.rule}: ${refusal.detail}`);
|
|
461
|
+
console.error(`rigc: ${refusals.length} refusal(s) — nothing appended to ${ledgerPath}`);
|
|
462
|
+
process.exit(1);
|
|
463
|
+
}
|
|
464
|
+
for (const rule of VOTE_RULES) console.log(` PASS ${rule}`);
|
|
465
|
+
|
|
466
|
+
line.seq = existing.length + 1;
|
|
467
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
468
|
+
appendFileSync(ledgerPath, ledgerLineText(line));
|
|
469
|
+
console.log(
|
|
470
|
+
` .. ${line.choice === TIE ? 'tie' : `winner ${line.choice} = ${line.winner}`}, ` +
|
|
471
|
+
`reason code ${line.reasonCode}${line.attempt > 1 ? `, attempt ${line.attempt}` : ''}`,
|
|
472
|
+
);
|
|
473
|
+
console.log(
|
|
474
|
+
` .. coverage ${line.coverage.length} candidate(s): ` +
|
|
475
|
+
line.coverage.map((c) => `${c.label}=${c.digest.slice(0, 'sha256:'.length + 12)}…`).join(' '),
|
|
476
|
+
);
|
|
477
|
+
console.log(`rigc: appended line ${line.seq} to ${ledgerPath}`);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function cmdVote(flags: Record<string, string>, candidates: string[]): void {
|
|
481
|
+
if (flags.record !== undefined) cmdVoteRecord(flags);
|
|
482
|
+
else if (candidates.length > 0) cmdVoteBallot(flags, candidates);
|
|
483
|
+
else {
|
|
484
|
+
throw new UsageError(
|
|
485
|
+
'vote takes either 2–4 --candidate <dir | skeleton.json> to write a ballot, or --record <result.json> to ' +
|
|
486
|
+
'record one that came back',
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* bench — run one rung of the benchmark ladder against a candidate rig.
|
|
493
|
+
*
|
|
494
|
+
* Two questions, asked in this order and never merged:
|
|
495
|
+
*
|
|
496
|
+
* 1. Is the candidate valid Spine at all? That is `validate --profile spine`,
|
|
497
|
+
* and it is the only part with a pass/fail. The profile is pinned here, not
|
|
498
|
+
* inherited from the CLI default: the thing being reproduced is an editor
|
|
499
|
+
* export, and holding it to this project's renderer policy would fail rungs
|
|
500
|
+
* for reasons the rung is not about.
|
|
501
|
+
* 2. How close is it, structurally, to the reference? That is `diff`, and it
|
|
502
|
+
* has no threshold at all. There is no score to pass, on purpose — see
|
|
503
|
+
* `src/diff.ts`. A rung is called cleared by a human reading the measures,
|
|
504
|
+
* and `docs/LADDER.md` records that judgement.
|
|
505
|
+
*
|
|
506
|
+
* ⚠️ The candidate is validated against the SPINE profile and compared against
|
|
507
|
+
* the reference; the reference is never validated here. It is editor output and
|
|
508
|
+
* is the definition of correct for this exercise, so gating it would be gating
|
|
509
|
+
* the yardstick with the ruler.
|
|
510
|
+
*/
|
|
511
|
+
export function cmdBench(flags: Record<string, string>, positional: string[]): void {
|
|
512
|
+
const rungId = positional[0];
|
|
513
|
+
if (!rungId) throw new UsageError(`bench takes a rung: ${RUNG_IDS.join(' | ')}`);
|
|
514
|
+
const rung = findRung(rungId);
|
|
515
|
+
if (!rung) throw new UsageError(`unknown rung ${JSON.stringify(rungId)}; known: ${RUNG_IDS.join(', ')}`);
|
|
516
|
+
if (flags.candidate === undefined) throw new UsageError('bench needs --candidate <dir | skeleton.json>');
|
|
517
|
+
|
|
518
|
+
// bench judges a reproduction of editor output, so `spine` is PINNED here
|
|
519
|
+
// rather than inherited. It reads the same as the CLI default today (#221) and
|
|
520
|
+
// is kept as its own statement anyway: the ladder's stage-1 gate is defined by
|
|
521
|
+
// `docs/GATE.md` as `validate --profile spine`, and a bench run must go on
|
|
522
|
+
// meaning that whatever a later release decides the default should be.
|
|
523
|
+
const profile: ValidateProfile = flags.profile === undefined ? 'spine' : readProfile(flags);
|
|
524
|
+
const exportDir = resolve(PACKAGE_ROOT, 'examples', rung.example, 'export');
|
|
525
|
+
if (!existsSync(exportDir)) {
|
|
526
|
+
// `bun run fetch-examples` runs `scripts/fetch-examples.sh`, and `scripts/`
|
|
527
|
+
// is not in package.json's `files` — an npm install has no such script to
|
|
528
|
+
// run. Its presence is what tells the two contexts apart, so the remedy
|
|
529
|
+
// named here is one that actually exists in whichever context this is.
|
|
530
|
+
const remedy = existsSync(resolve(PACKAGE_ROOT, 'scripts', 'fetch-examples.sh'))
|
|
531
|
+
? 'run `bun run fetch-examples` first'
|
|
532
|
+
: `bench needs a checkout of ${repositoryUrl()} — its \`fetch-examples\` script is not part of the installed package`;
|
|
533
|
+
throw new UsageError(`no example corpus at ${exportDir} — ${remedy} (examples/ is gitignored, not shipped)`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Both files there and the skeleton JSON before the first line is printed, refused by name as `render` refuses
|
|
537
|
+
// them (issue #1042): a missing file and a skeleton that is not JSON surfaced as an ENOENT or a SyntaxError and a
|
|
538
|
+
// stack, the second only after the validate block had printed.
|
|
539
|
+
const { skeletonPath, atlasPath } = resolveViewable({ candidate: flags.candidate, ...(flags.atlas === undefined ? {} : { atlas: flags.atlas }) });
|
|
540
|
+
const skeletonText = readSkeletonText(skeletonPath);
|
|
541
|
+
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
542
|
+
|
|
543
|
+
console.log(`rigc bench rung ${rung.id} — ${rung.example}`);
|
|
544
|
+
console.log(` gates ${rung.gates}`);
|
|
545
|
+
console.log(` candidate ${skeletonPath}`);
|
|
546
|
+
console.log(` atlas ${atlasPath}`);
|
|
547
|
+
console.log('');
|
|
548
|
+
|
|
549
|
+
console.log(` ── validate (profile ${profile}) ──`);
|
|
550
|
+
const benchModel = modelTextBeside(skeletonPath, skeletonText);
|
|
551
|
+
const report = validate({ skeletonText, atlasText, atlasDir: dirname(atlasPath), ...(benchModel === undefined ? {} : { modelText: benchModel }), profile });
|
|
552
|
+
for (const line of reportLines(report)) console.log(` ${line}`);
|
|
553
|
+
console.log('');
|
|
554
|
+
|
|
555
|
+
const candidateJson: unknown = JSON.parse(skeletonText);
|
|
556
|
+
const diffs: Array<{ skeleton: RungSkeleton; reference: string; report: DiffReport }> = [];
|
|
557
|
+
for (const skeleton of rung.skeletons) {
|
|
558
|
+
const referencePath = join(exportDir, skeleton.file);
|
|
559
|
+
if (!existsSync(referencePath)) {
|
|
560
|
+
console.error(` MISSING ${referencePath} — re-run \`bun run fetch-examples\``);
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
const role = skeleton.role === 'stretch' ? ' (stretch — reported, does not count)' : '';
|
|
564
|
+
console.log(` ── diff vs ${rung.example}/${skeleton.label}${role} ──`);
|
|
565
|
+
const diff = diffSkeletons(candidateJson, JSON.parse(readFileSync(referencePath, 'utf8')));
|
|
566
|
+
for (const line of diffLines(diff, { candidate: skeletonPath, reference: referencePath })) console.log(` ${line}`);
|
|
567
|
+
console.log('');
|
|
568
|
+
diffs.push({ skeleton, reference: referencePath, report: diff });
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// Stage 3, optional and behind a flag because the correspondence is an INPUT:
|
|
572
|
+
// a candidate is entitled to its own bone names, so there is nothing sensible
|
|
573
|
+
// to default to and a derived mapping would be a guess reported as a
|
|
574
|
+
// measurement (issue #8). Nothing here gates, and without the flag the report
|
|
575
|
+
// above is unchanged to the byte.
|
|
576
|
+
const boneDists: Array<{ skeleton: RungSkeleton; report: BoneDistReport }> = [];
|
|
577
|
+
if (flags.bones !== undefined) {
|
|
578
|
+
for (const skeleton of rung.skeletons) {
|
|
579
|
+
const referencePath = join(exportDir, skeleton.file);
|
|
580
|
+
if (!existsSync(referencePath)) continue;
|
|
581
|
+
console.log(` ── bonedist vs ${rung.example}/${skeleton.label} (stage 3) ──`);
|
|
582
|
+
const boneDist = boneDistance({
|
|
583
|
+
candidateSkeleton: skeletonPath,
|
|
584
|
+
candidateAtlas: atlasPath,
|
|
585
|
+
referenceSkeleton: referencePath,
|
|
586
|
+
referenceAtlas: join(exportDir, skeleton.atlas),
|
|
587
|
+
bones: flags.bones,
|
|
588
|
+
// Deliberately NOT `flags.fps`. Inside `bench` that flag already means
|
|
589
|
+
// "the rate this frame set was recorded at, for a set with no sidecar",
|
|
590
|
+
// and one flag doing two unrelated things in one command is how a
|
|
591
|
+
// reader ends up quoting a figure measured at a rate they did not ask
|
|
592
|
+
// for. A run wanting another sampling rate calls `rigc bonedist`, where
|
|
593
|
+
// `--fps` has exactly one meaning.
|
|
594
|
+
});
|
|
595
|
+
for (const line of boneDistLines(boneDist, { allBones: flags['all-bones'] !== undefined })) console.log(` ${line}`);
|
|
596
|
+
console.log('');
|
|
597
|
+
boneDists.push({ skeleton, report: boneDist });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Third, optional and third for a reason: is it the same MOTION? `diff`
|
|
602
|
+
// compares structure, and a reversed easing is the same key count and the same
|
|
603
|
+
// curve kind — so a row of this ladder carrying only `validate` and `diff`
|
|
604
|
+
// records a rig that could be animated backwards. `--frames` folds `check`'s
|
|
605
|
+
// table into the report so a future row carries both.
|
|
606
|
+
let check: CheckReport | null = null;
|
|
607
|
+
if (flags.frames !== undefined) {
|
|
608
|
+
console.log(` ── check vs frames ${resolve(flags.frames)} ──`);
|
|
609
|
+
check = runCheck(flags.candidate, flags.atlas, flags.frames, flags);
|
|
610
|
+
for (const line of checkLines(check, { allFrames: flags['all-frames'] !== undefined })) console.log(` ${line}`);
|
|
611
|
+
console.log('');
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
console.log(' ── summary ──');
|
|
615
|
+
console.log(` validate ${report.failures.length === 0 ? 'green' : `${report.failures.length} FAILED`} (profile ${profile})`);
|
|
616
|
+
for (const d of diffs) {
|
|
617
|
+
const means = d.report.sections.map((s) => `${s.name}=${s.ratio.toFixed(3)}`).join(' ');
|
|
618
|
+
console.log(` ${d.skeleton.label.padEnd(10)} ${means}${d.skeleton.role === 'stretch' ? ' [stretch]' : ''}`);
|
|
619
|
+
// Second line, not folded into the first: the figures above are the ones
|
|
620
|
+
// every bench.json on disk already carries, and a ladder record is worth
|
|
621
|
+
// less the moment its headline stops meaning what the older ones meant.
|
|
622
|
+
// The sections whose measures are dominated by name-keyed ones get their
|
|
623
|
+
// name-agnostic figure printed beside — issue #21.
|
|
624
|
+
const split = d.report.sections.filter((s) => s.nameAgnostic !== undefined);
|
|
625
|
+
if (split.length > 0) console.log(` ${''.padEnd(10)} ${split.map(sectionFigures).join(' ')}`);
|
|
626
|
+
// A third line, for the same reason the second one is not folded into the
|
|
627
|
+
// first: the reported measures are unobservable by construction, so they
|
|
628
|
+
// roll into no mean at all and cannot be shown as one. Each is named with
|
|
629
|
+
// its own figure — a per-section digest would be the mean this block exists
|
|
630
|
+
// to refuse.
|
|
631
|
+
const reported = reportedFigures(d.report);
|
|
632
|
+
if (reported !== null) console.log(` ${''.padEnd(10)} reported: ${reported}`);
|
|
633
|
+
}
|
|
634
|
+
if (check) {
|
|
635
|
+
// The framing goes first because it is upstream of every MAE below it: a
|
|
636
|
+
// summary that reported those numbers without saying how the two shots were
|
|
637
|
+
// put on each other is how issue #34 stayed invisible for two ladder runs.
|
|
638
|
+
const framing = check.framingFit;
|
|
639
|
+
if (!framing && check.sharedFraming) {
|
|
640
|
+
const f = check.sharedFraming.fit;
|
|
641
|
+
console.log(
|
|
642
|
+
` framing one per set (${check.animations.length}); one shared box leaves ` +
|
|
643
|
+
`x${f.scale.toFixed(6)}, rms ${f.rms.toFixed(2)}px — see the check table above for each set's own`,
|
|
644
|
+
);
|
|
645
|
+
}
|
|
646
|
+
if (framing) {
|
|
647
|
+
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
648
|
+
const how = !framing.applied
|
|
649
|
+
? 'measured only — --viewport pinned'
|
|
650
|
+
: framing.source === 'declared'
|
|
651
|
+
? `frames.json's own box, the candidate measured into it`
|
|
652
|
+
: `fitted to the candidate's pixels, ${framing.passes} pass(es)${framing.settled ? '' : framing.cycled ? ', cycling' : ', unsettled'}`;
|
|
653
|
+
// The MAE-refined offset belongs on this line rather than only in `check`'s
|
|
654
|
+
// own table: it moved the box every figure below was measured in, so a row
|
|
655
|
+
// that quoted the figures without it would not say what they were measured
|
|
656
|
+
// against — issue #146's own version of the #34 lesson above.
|
|
657
|
+
const r = framing.refinement;
|
|
658
|
+
const refined =
|
|
659
|
+
r === null || !r.applied
|
|
660
|
+
? ''
|
|
661
|
+
: ` MAE-refined ${signed(r.dx)}, ${signed(r.dy)}px (${r.before.toFixed(2)} → ${r.after.toFixed(2)} ref)`;
|
|
662
|
+
console.log(
|
|
663
|
+
` framing fit x${framing.fit.scale.toFixed(6)} rms ${framing.fit.rms.toFixed(2)}px union residual ` +
|
|
664
|
+
`${signed(framing.fit.residualWidth)} x ${signed(framing.fit.residualHeight)}px (${how})${refined}`,
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
for (const anim of check.animations) {
|
|
668
|
+
const attributed = anim.compared - anim.framesWithoutDrift;
|
|
669
|
+
const drift =
|
|
670
|
+
anim.worstDriftFrame < 0
|
|
671
|
+
? 'no slot attributable in any of them'
|
|
672
|
+
: `worst slot drift ${anim.worstDrift.toFixed(1)}px, attributed in ${attributed}`;
|
|
673
|
+
// The per-frame change count is carried here and not only in `check`'s own
|
|
674
|
+
// table because it is the one figure a flat MAE cannot imply: a shot can be
|
|
675
|
+
// right at every frame and still hold or blink at the wrong moments.
|
|
676
|
+
const change =
|
|
677
|
+
anim.changeDisagreements === 0
|
|
678
|
+
? ''
|
|
679
|
+
: `, ${anim.changeDisagreements}/${anim.changePairs} pair(s) change unlike the reference`;
|
|
680
|
+
// Which of the candidate's own bone chains the error is in — one name, so a
|
|
681
|
+
// loop between builds reads a unit to fix rather than a verdict on the shot.
|
|
682
|
+
// The full table is in `check`'s own report; this is its headline.
|
|
683
|
+
const worstChain = [...anim.chains].sort((a, b) => b.maeShare - a.maeShare)[0];
|
|
684
|
+
const chain =
|
|
685
|
+
worstChain === undefined ? '' : `, ${worstChain.chain} carries ${(worstChain.maeShare * 100).toFixed(0)}%`;
|
|
686
|
+
// `ref=` is the same difference over the reference's own drawn pixels. It is
|
|
687
|
+
// carried here and not only in `check`'s own table because this is the line a
|
|
688
|
+
// loop reads between builds, and `mean=` has a denominator the candidate can
|
|
689
|
+
// grow — see `FrameCheck.maeReference`.
|
|
690
|
+
// ...and the contact sheet, when the set ships one: a row reading "over 2
|
|
691
|
+
// frame(s)" for a 311-frame shot is the hole issue #36 closed, and the whole
|
|
692
|
+
// -shot figure is the one that says the frames between the stills were seen.
|
|
693
|
+
const sheet =
|
|
694
|
+
anim.sheet === null
|
|
695
|
+
? ''
|
|
696
|
+
: `, sheet ${anim.sheet.compared} tile(s) mean=${anim.sheet.meanMae.toFixed(2)} ` +
|
|
697
|
+
`worst=${anim.sheet.worstMae.toFixed(2)}`;
|
|
698
|
+
console.log(
|
|
699
|
+
` ${anim.dir.padEnd(10)} MAE mean=${anim.meanMae.toFixed(2)} worst=${anim.worstMae.toFixed(2)} ` +
|
|
700
|
+
`ref=${anim.meanMaeReference.toFixed(2)} over ${anim.compared} frame(s) ${drift}${change}${chain}${sheet}`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
} else {
|
|
704
|
+
console.log(' check not run — pass --frames <dir> to compare against the rendered reference frames.');
|
|
705
|
+
console.log(' Without it this report says nothing about whether the ANIMATION is right.');
|
|
706
|
+
}
|
|
707
|
+
if (boneDists.length > 0) {
|
|
708
|
+
for (const b of boneDists) {
|
|
709
|
+
const w = b.report.worst;
|
|
710
|
+
console.log(
|
|
711
|
+
` ${b.skeleton.label.padEnd(10)} bonedist worst position ${w.position.value.toFixed(6)} skeleton-size(s), ` +
|
|
712
|
+
`rotation ${w.rotation.value.toFixed(4)}°, scale ${w.scale.value.toFixed(6)}, linear ${w.linear.value.toFixed(6)} ` +
|
|
713
|
+
`over ${b.report.animations.reduce((n, a) => n + a.compared, 0)} frame(s) × ${b.report.correspondence.pairs} bone pair(s)`,
|
|
714
|
+
);
|
|
715
|
+
}
|
|
716
|
+
} else {
|
|
717
|
+
console.log(' bonedist not run — pass --bones <correspondence.json | identity> for the stage-3 per-frame');
|
|
718
|
+
console.log(' bone world-transform distance. It reports and gates nothing.');
|
|
719
|
+
}
|
|
720
|
+
console.log(' Section figures are means of their own measures. There is no rung score:');
|
|
721
|
+
console.log(' a rung is cleared by a person reading the measures, and docs/LADDER.md records it.');
|
|
722
|
+
|
|
723
|
+
if (flags.json !== undefined) {
|
|
724
|
+
// No `gates` field, deliberately. The rung's gate string names its features
|
|
725
|
+
// and its per-skeleton counts, which `bench/runs/README.md` forbids a run
|
|
726
|
+
// from reading — and this report is one of the six files the run protocol
|
|
727
|
+
// requires committing, so a copy of it here would sit inside every future
|
|
728
|
+
// run's directory, which is exactly where the next author looks for process
|
|
729
|
+
// notes. `rung` identifies the rung and carries nothing (issue #137). The
|
|
730
|
+
// console block above still prints the gate string: that is for the person
|
|
731
|
+
// reading the run, not a file the protocol commits.
|
|
732
|
+
writeJson(flags.json, {
|
|
733
|
+
rung: rung.id,
|
|
734
|
+
example: rung.example,
|
|
735
|
+
profile,
|
|
736
|
+
candidate: { skeleton: skeletonPath, atlas: atlasPath },
|
|
737
|
+
validate: report,
|
|
738
|
+
// `referencePath`, not `reference`: a DiffReport already has a
|
|
739
|
+
// `reference` of its own (the raw counts), and the spread wins.
|
|
740
|
+
diffs: diffs.map((d) => ({
|
|
741
|
+
label: d.skeleton.label,
|
|
742
|
+
role: d.skeleton.role,
|
|
743
|
+
referencePath: d.reference,
|
|
744
|
+
...d.report,
|
|
745
|
+
})),
|
|
746
|
+
check,
|
|
747
|
+
// Absent rather than null when the flag was not passed: `bonedist: null`
|
|
748
|
+
// in a stored record would read as "measured, nothing to report", and
|
|
749
|
+
// that is the opposite of "not measured".
|
|
750
|
+
...(boneDists.length === 0
|
|
751
|
+
? {}
|
|
752
|
+
: { boneDists: boneDists.map((b) => ({ label: b.skeleton.label, role: b.skeleton.role, ...b.report })) }),
|
|
753
|
+
});
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
if (report.failures.length > 0) {
|
|
757
|
+
console.error(`rigc: candidate is not valid Spine — ${report.failures.length} assertion(s) failed`);
|
|
758
|
+
process.exit(1);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* bonedist — the ladder's stage 3, run on its own.
|
|
764
|
+
*
|
|
765
|
+
* ⚠️ It reads BOTH skeletons, so it is a finish-line instrument like `bench` and
|
|
766
|
+
* unlike `check`. Every convention behind every figure is printed above the
|
|
767
|
+
* tables, and there is no score — see [`src/bonedist.ts`](src/bonedist.ts).
|
|
768
|
+
*/
|
|
769
|
+
export function cmdBoneDist(flags: Record<string, string>): void {
|
|
770
|
+
if (flags.candidate === undefined) throw new UsageError('bonedist needs --candidate <dir | skeleton.json>');
|
|
771
|
+
if (flags.reference === undefined) throw new UsageError('bonedist needs --reference <skeleton.json>');
|
|
772
|
+
if (flags.bones === undefined) {
|
|
773
|
+
throw new UsageError(
|
|
774
|
+
`bonedist needs --bones <correspondence.json | ${IDENTITY_CORRESPONDENCE}> — a candidate is entitled to its own bone ` +
|
|
775
|
+
'names, so the mapping is an input and never a guess; pass `identity` to state that the two use the same names',
|
|
776
|
+
);
|
|
777
|
+
}
|
|
778
|
+
// Each side's files there and its skeleton JSON, refused by name as `render` refuses them (issue #1042); a pair
|
|
779
|
+
// that does not load is `boneDistance`'s refusal (`CandidatePairError`, exit 2 below).
|
|
780
|
+
const sideOf = (target: string, atlas: string | undefined): { skeletonPath: string; atlasPath: string } => {
|
|
781
|
+
const side = resolveViewable({ candidate: target, ...(atlas === undefined ? {} : { atlas }) });
|
|
782
|
+
readSkeletonText(side.skeletonPath);
|
|
783
|
+
return side;
|
|
784
|
+
};
|
|
785
|
+
const candidate = sideOf(flags.candidate, flags.atlas);
|
|
786
|
+
const reference = sideOf(flags.reference, flags['reference-atlas']);
|
|
787
|
+
const report = boneDistance({
|
|
788
|
+
candidateSkeleton: candidate.skeletonPath,
|
|
789
|
+
candidateAtlas: candidate.atlasPath,
|
|
790
|
+
referenceSkeleton: reference.skeletonPath,
|
|
791
|
+
referenceAtlas: reference.atlasPath,
|
|
792
|
+
bones: flags.bones,
|
|
793
|
+
...(flags.fps === undefined ? {} : { fps: Number(flags.fps) }),
|
|
794
|
+
});
|
|
795
|
+
console.log('rigc bonedist — per-frame bone world-transform distance (the ladder\'s stage 3)');
|
|
796
|
+
for (const line of boneDistLines(report, { allBones: flags['all-bones'] !== undefined })) console.log(line);
|
|
797
|
+
if (flags.json !== undefined) writeJson(flags.json, report);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* The bodies of every command whose `runtime` is not `false` (`COMMANDS`), by
|
|
802
|
+
* name — what only `cli.ts` registers, beside `./core_commands.ts`'s.
|
|
803
|
+
*/
|
|
804
|
+
export const SPINE_COMMAND_RUNS: Readonly<Record<string, CommandRun>> = {
|
|
805
|
+
build: ({ flags }) => cmdBuild(flags),
|
|
806
|
+
repack: (args) => cmdRepack(args, ROUND_TRIP_GATE),
|
|
807
|
+
validate: ({ flags, positional }) => cmdValidate(flags, positional),
|
|
808
|
+
bench: ({ flags, positional }) => cmdBench(flags, positional),
|
|
809
|
+
bonedist: ({ flags }) => cmdBoneDist(flags),
|
|
810
|
+
preview: ({ flags, lists }) => cmdPreview(flags, lists.candidate ?? []),
|
|
811
|
+
vote: ({ flags, lists }) => cmdVote(flags, lists.candidate ?? []),
|
|
812
|
+
};
|
|
813
|
+
|
|
814
|
+
/** `bonedist`'s refusal, whose class lives in the module that poses through spine-core (`CliEntry.refusals`). */
|
|
815
|
+
export const SPINE_COMMAND_REFUSALS: readonly CliRefusal[] = [
|
|
816
|
+
{ is: (err) => err instanceof BoneDistError, prefix: 'rigc bonedist error: ', status: 1 },
|
|
817
|
+
];
|
|
818
|
+
|
|
819
|
+
/** Each profile's rule count — the validator's, which the usage's `--profile` paragraph states. */
|
|
820
|
+
export const PROFILE_RULES = (profile: 'spine' | 'spine-html'): number => assertionCountForProfile(profile);
|