spine-rigc 0.6.0 → 0.8.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 +147 -11
- package/cli.ts +482 -15
- package/docs/AUTHORING.md +426 -10
- package/docs/MOTION.md +990 -0
- package/package.json +3 -1
- package/src/ballot.ts +869 -0
- package/src/compile.ts +612 -0
- package/src/pose.ts +1386 -0
- package/src/preview.ts +2 -2
- package/src/types.ts +163 -0
- package/src/validate.ts +229 -1
package/cli.ts
CHANGED
|
@@ -31,14 +31,38 @@
|
|
|
31
31
|
* Its paths resolve against the cuts.json file itself, so the table travels
|
|
32
32
|
* with the project that owns the art rather than with this repository.
|
|
33
33
|
*/
|
|
34
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
34
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
35
35
|
import { basename, dirname, join, resolve } from 'node:path';
|
|
36
|
+
import {
|
|
37
|
+
BallotError,
|
|
38
|
+
buildBallot,
|
|
39
|
+
ledgerLineText,
|
|
40
|
+
MAX_CANDIDATES,
|
|
41
|
+
MIN_CANDIDATES,
|
|
42
|
+
parseLedger,
|
|
43
|
+
readBallotManifest,
|
|
44
|
+
resultFilename,
|
|
45
|
+
TIE,
|
|
46
|
+
verifyResult,
|
|
47
|
+
VOTE_RULES,
|
|
48
|
+
type BallotCandidateInput,
|
|
49
|
+
type BallotInput,
|
|
50
|
+
} from './src/ballot.ts';
|
|
36
51
|
import { checkAgainstFrames, checkLines, CheckError, type CheckOptions, type CheckReport } from './src/check.ts';
|
|
37
52
|
import { compile, CompileError, type CompileOptions } from './src/compile.ts';
|
|
38
53
|
import { diffLines, diffSkeletons, sectionFigures, type DiffReport } from './src/diff.ts';
|
|
39
54
|
import { copyAtlasImages } from './src/emit.ts';
|
|
40
55
|
import { parseJsonWithPosition } from './src/json-position.ts';
|
|
41
56
|
import { findRung, RUNG_IDS, type RungSkeleton } from './src/ladder.ts';
|
|
57
|
+
import {
|
|
58
|
+
DEFAULT_MAX_RESIDUAL,
|
|
59
|
+
DEFAULT_SCALE_MAX,
|
|
60
|
+
DEFAULT_SCALE_MIN,
|
|
61
|
+
estimatePose,
|
|
62
|
+
PoseError,
|
|
63
|
+
poseLines,
|
|
64
|
+
type PoseOptions,
|
|
65
|
+
} from './src/pose.ts';
|
|
42
66
|
import { buildPreview, PLAYER_LINE, type PreviewPage } from './src/preview.ts';
|
|
43
67
|
import {
|
|
44
68
|
atlasPageNames,
|
|
@@ -128,31 +152,63 @@ function repositoryUrl(): string {
|
|
|
128
152
|
* flag": inferring it would turn `--out --json report.json` — a real typo, a
|
|
129
153
|
* missing value — into a silently accepted switch plus a stray positional.
|
|
130
154
|
*/
|
|
131
|
-
const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images']);
|
|
155
|
+
const BOOLEAN_FLAGS = new Set(['all-frames', 'help', 'copy-images', 'again']);
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* The flags a command is allowed to spell more than once.
|
|
159
|
+
*
|
|
160
|
+
* Only `vote --candidate` is, because a ballot is *by definition* several
|
|
161
|
+
* candidates. Everywhere else a repeat is a mistake and is refused: `check
|
|
162
|
+
* --candidate a --candidate b` used to take `b` silently, which is a report
|
|
163
|
+
* about a rig the caller did not think they were asking about.
|
|
164
|
+
*/
|
|
165
|
+
const REPEATABLE_FLAGS: Record<string, ReadonlySet<string>> = {
|
|
166
|
+
vote: new Set(['candidate']),
|
|
167
|
+
};
|
|
132
168
|
|
|
133
|
-
/**
|
|
134
|
-
|
|
169
|
+
/**
|
|
170
|
+
* `--flag value` pairs plus the leftover positionals, in order.
|
|
171
|
+
*
|
|
172
|
+
* `lists` carries every occurrence of every flag and `flags` carries the last
|
|
173
|
+
* one, so a command that wants a repeated flag reads `lists` and the ones that
|
|
174
|
+
* do not are untouched by the addition.
|
|
175
|
+
*/
|
|
176
|
+
function parseArgs(
|
|
177
|
+
argv: string[],
|
|
178
|
+
repeatable: ReadonlySet<string> = new Set(),
|
|
179
|
+
): { flags: Record<string, string>; lists: Record<string, string[]>; positional: string[] } {
|
|
135
180
|
const flags: Record<string, string> = {};
|
|
181
|
+
const lists: Record<string, string[]> = {};
|
|
136
182
|
const positional: string[] = [];
|
|
183
|
+
const take = (name: string, value: string): void => {
|
|
184
|
+
if (flags[name] !== undefined && !repeatable.has(name)) {
|
|
185
|
+
throw new UsageError(
|
|
186
|
+
`--${name} was given more than once (${JSON.stringify(flags[name])} then ${JSON.stringify(value)}); ` +
|
|
187
|
+
'this command takes it once',
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
flags[name] = value;
|
|
191
|
+
(lists[name] ??= []).push(value);
|
|
192
|
+
};
|
|
137
193
|
for (let i = 0; i < argv.length; i++) {
|
|
138
194
|
const arg = argv[i];
|
|
139
195
|
if (arg.startsWith('--')) {
|
|
140
196
|
const eq = arg.indexOf('=');
|
|
141
197
|
if (eq !== -1) {
|
|
142
|
-
|
|
198
|
+
take(arg.slice(2, eq), arg.slice(eq + 1));
|
|
143
199
|
} else if (BOOLEAN_FLAGS.has(arg.slice(2))) {
|
|
144
|
-
|
|
200
|
+
take(arg.slice(2), 'true');
|
|
145
201
|
} else {
|
|
146
202
|
const next = argv[i + 1];
|
|
147
203
|
if (next === undefined || next.startsWith('--')) throw new UsageError(`${arg} needs a value`);
|
|
148
|
-
|
|
204
|
+
take(arg.slice(2), next);
|
|
149
205
|
i++;
|
|
150
206
|
}
|
|
151
207
|
} else {
|
|
152
208
|
positional.push(arg);
|
|
153
209
|
}
|
|
154
210
|
}
|
|
155
|
-
return { flags, positional };
|
|
211
|
+
return { flags, lists, positional };
|
|
156
212
|
}
|
|
157
213
|
|
|
158
214
|
/**
|
|
@@ -726,6 +782,288 @@ function cmdPreview(flags: Record<string, string>): void {
|
|
|
726
782
|
console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
|
|
727
783
|
}
|
|
728
784
|
|
|
785
|
+
// ---------------------------------------------------------------------------
|
|
786
|
+
// reading a given condition — pose
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
//
|
|
789
|
+
// ⭐ Every other command here takes a spec and looks at what came out. This one
|
|
790
|
+
// runs the other way: it takes a PICTURE the user already has — a key pose — and
|
|
791
|
+
// reads spec coordinates out of it, so an agent can state those poses in a rig and
|
|
792
|
+
// a motion by construction and spend its loops on the part nobody can measure, the
|
|
793
|
+
// movement between them.
|
|
794
|
+
//
|
|
795
|
+
// 🚫 It grades nothing, and the distinction is load-bearing rather than modest.
|
|
796
|
+
// `check` and `bench` compare a build against a reference and their numbers mean
|
|
797
|
+
// "how close"; a pose frame is not a reference, it is an INPUT, and once the spec
|
|
798
|
+
// states it there is nothing left to be close to. So the residual here is a trust
|
|
799
|
+
// signal — how much of the frame this placement actually explains — and the only
|
|
800
|
+
// threshold in `src/pose.ts` is the one that decides whether to print an answer at
|
|
801
|
+
// all, which the caller can move.
|
|
802
|
+
//
|
|
803
|
+
// rigc pose --images parts/ --frame poseA.png [--out pose.json]
|
|
804
|
+
|
|
805
|
+
const DEFAULT_POSE_OUT = 'pose.json';
|
|
806
|
+
|
|
807
|
+
/** `--scale 0.5,2` / `--rotation -30,30` — a pair of numbers, low first. */
|
|
808
|
+
function readRange(flags: Record<string, string>, key: string): { low: number; high: number } | undefined {
|
|
809
|
+
const raw = flags[key];
|
|
810
|
+
if (raw === undefined) return undefined;
|
|
811
|
+
const parts = raw.split(',').map((s) => Number(s.trim()));
|
|
812
|
+
if (parts.length !== 2 || parts.some((n) => !Number.isFinite(n))) {
|
|
813
|
+
throw new UsageError(`--${key} takes two numbers: <min>,<max>`);
|
|
814
|
+
}
|
|
815
|
+
if (parts[1] < parts[0]) throw new UsageError(`--${key} ${JSON.stringify(raw)}: the minimum must not exceed the maximum`);
|
|
816
|
+
return { low: parts[0], high: parts[1] };
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function cmdPose(flags: Record<string, string>): void {
|
|
820
|
+
if (flags.images === undefined) throw new UsageError('pose needs --images <dir> — the directory the loose part PNGs are in');
|
|
821
|
+
if (flags.frame === undefined) throw new UsageError('pose needs --frame <path> — one pose frame to read the placements out of');
|
|
822
|
+
const options: PoseOptions = { imagesDir: flags.images, framePath: flags.frame };
|
|
823
|
+
const scale = readRange(flags, 'scale');
|
|
824
|
+
if (scale) {
|
|
825
|
+
if (scale.low <= 0) throw new UsageError('--scale minimum must be greater than zero');
|
|
826
|
+
options.scale = { min: scale.low, max: scale.high };
|
|
827
|
+
}
|
|
828
|
+
const rotation = readRange(flags, 'rotation');
|
|
829
|
+
if (rotation) {
|
|
830
|
+
if (rotation.high - rotation.low > 360) throw new UsageError('--rotation cannot span more than a full turn');
|
|
831
|
+
options.rotation = { minDeg: rotation.low, maxDeg: rotation.high };
|
|
832
|
+
}
|
|
833
|
+
if (flags['max-residual'] !== undefined) {
|
|
834
|
+
const value = Number(flags['max-residual']);
|
|
835
|
+
if (!Number.isFinite(value) || value <= 0 || value > 1) throw new UsageError('--max-residual must be a number in (0, 1]');
|
|
836
|
+
options.maxResidual = value;
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
console.log('rigc pose');
|
|
840
|
+
const report = estimatePose(options);
|
|
841
|
+
for (const line of poseLines(report)) console.log(line);
|
|
842
|
+
|
|
843
|
+
// Same `--out` shape as `preview` and `vote`: one file, and a directory means
|
|
844
|
+
// "the default name in here" rather than a report written over a directory.
|
|
845
|
+
const target = resolve(flags.out ?? DEFAULT_POSE_OUT);
|
|
846
|
+
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_POSE_OUT) : target;
|
|
847
|
+
writeJson(out, report);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
// choosing between results — vote
|
|
852
|
+
// ---------------------------------------------------------------------------
|
|
853
|
+
//
|
|
854
|
+
// ⭐ `preview` shows one candidate; this shows two to four of them side by side
|
|
855
|
+
// and takes an answer back. The rest of this toolchain is instruments, and it
|
|
856
|
+
// should be — the vote opens only where the instruments have already run out.
|
|
857
|
+
// See `src/ballot.ts` for why the ballot is ordered compile-first-vote-last,
|
|
858
|
+
// why the labels are A and B, and why the record is hashes.
|
|
859
|
+
//
|
|
860
|
+
// Two modes on one command, because they share exactly one thing and it is the
|
|
861
|
+
// contract between them: the ballot manifest. Splitting them would document
|
|
862
|
+
// that format twice and let the halves drift.
|
|
863
|
+
//
|
|
864
|
+
// rigc vote --candidate <a> --candidate <b> [--animation <n>] [--out ballot.html]
|
|
865
|
+
// rigc vote --record <result.json> [--ballot ballot.html] [--ledger votes.jsonl] [--again]
|
|
866
|
+
|
|
867
|
+
const DEFAULT_BALLOT = 'ballot.html';
|
|
868
|
+
const DEFAULT_LEDGER = 'votes.jsonl';
|
|
869
|
+
|
|
870
|
+
/** Load one candidate off disk in the shape a ballot needs. */
|
|
871
|
+
function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
|
|
872
|
+
const { skeletonPath, atlasPath } = resolveArtifacts(target, undefined);
|
|
873
|
+
for (const path of [skeletonPath, atlasPath]) {
|
|
874
|
+
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
875
|
+
}
|
|
876
|
+
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
877
|
+
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
878
|
+
const atlasDir = dirname(atlasPath);
|
|
879
|
+
const pages: PreviewPage[] = atlasPageNames(atlasText).map((name) => {
|
|
880
|
+
const path = join(atlasDir, name);
|
|
881
|
+
if (!existsSync(path)) {
|
|
882
|
+
throw new UsageError(
|
|
883
|
+
`the atlas declares page "${name}", which resolves to ${path} and is not there — ` +
|
|
884
|
+
'a page a ballot cannot embed is a page the player could not have loaded either',
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
return { name, bytes: readFileSync(path) };
|
|
888
|
+
});
|
|
889
|
+
return {
|
|
890
|
+
candidate: { source: skeletonPath, skeletonText, atlasText, pages },
|
|
891
|
+
animations: skeletonAnimationNames(skeletonText, skeletonPath),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* The one animation every candidate plays.
|
|
897
|
+
*
|
|
898
|
+
* ⚠️ Refused rather than resolved per candidate. Two panes running two
|
|
899
|
+
* different animations look like a comparison and are not one, and a voter has
|
|
900
|
+
* no way to see that it happened — the labels are `A` and `B`, which is the
|
|
901
|
+
* whole point, so nothing on the screen would say so.
|
|
902
|
+
*/
|
|
903
|
+
function commonAnimation(
|
|
904
|
+
flags: Record<string, string>,
|
|
905
|
+
loaded: { animations: string[] }[],
|
|
906
|
+
): string | null {
|
|
907
|
+
const asked = flags.animation;
|
|
908
|
+
if (asked === undefined) {
|
|
909
|
+
const first = loaded[0].animations[0];
|
|
910
|
+
if (first === undefined) {
|
|
911
|
+
const withAny = loaded.findIndex((l) => l.animations.length > 0);
|
|
912
|
+
if (withAny !== -1) {
|
|
913
|
+
throw new UsageError(
|
|
914
|
+
`candidate ${withAny + 1} has animations [${loaded[withAny].animations.join(', ')}] and candidate 1 has none — ` +
|
|
915
|
+
'a ballot plays one animation in every pane, so there is nothing to compare here',
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
return null;
|
|
919
|
+
}
|
|
920
|
+
const missing = loaded.findIndex((l) => !l.animations.includes(first));
|
|
921
|
+
if (missing !== -1) {
|
|
922
|
+
throw new UsageError(
|
|
923
|
+
`the default animation is candidate 1's first, ${JSON.stringify(first)}, and candidate ${missing + 1} does not ` +
|
|
924
|
+
`have it (it has [${loaded[missing].animations.join(', ') || 'none'}]); name one they share with --animation`,
|
|
925
|
+
);
|
|
926
|
+
}
|
|
927
|
+
return first;
|
|
928
|
+
}
|
|
929
|
+
const missing = loaded.findIndex((l) => !l.animations.includes(asked));
|
|
930
|
+
if (missing !== -1) {
|
|
931
|
+
throw new UsageError(
|
|
932
|
+
`no animation ${JSON.stringify(asked)} in candidate ${missing + 1}; it has ` +
|
|
933
|
+
`[${loaded[missing].animations.join(', ') || 'none'}]`,
|
|
934
|
+
);
|
|
935
|
+
}
|
|
936
|
+
return asked;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
/** vote (ballot mode) — write the page a human opens. */
|
|
940
|
+
function cmdVoteBallot(flags: Record<string, string>, candidates: string[]): void {
|
|
941
|
+
if (candidates.length < MIN_CANDIDATES) {
|
|
942
|
+
throw new UsageError(
|
|
943
|
+
`a ballot needs ${MIN_CANDIDATES}–${MAX_CANDIDATES} --candidate <dir | skeleton.json>, and ${candidates.length} ` +
|
|
944
|
+
'was given — one candidate on its own is `rigc preview`',
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
if (candidates.length > MAX_CANDIDATES) {
|
|
948
|
+
throw new UsageError(
|
|
949
|
+
`${candidates.length} candidates were given and a ballot holds at most ${MAX_CANDIDATES} — they go side by side ` +
|
|
950
|
+
'on one screen, and a comparison that needs scrolling is not a comparison',
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
// `--atlas` names ONE atlas and there are several skeletons here, so there is
|
|
954
|
+
// no unambiguous thing it could mean. Each candidate's atlas has to sit beside
|
|
955
|
+
// its skeleton, which is what `build --out` leaves behind.
|
|
956
|
+
if (flags.atlas !== undefined) {
|
|
957
|
+
throw new UsageError(
|
|
958
|
+
'--atlas names one atlas and a ballot has several candidates; each one\'s atlas has to sit beside its skeleton',
|
|
959
|
+
);
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const loaded = candidates.map((target) => loadBallotCandidate(target));
|
|
963
|
+
const animation = commonAnimation(flags, loaded);
|
|
964
|
+
|
|
965
|
+
const target = resolve(flags.out ?? DEFAULT_BALLOT);
|
|
966
|
+
const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_BALLOT) : target;
|
|
967
|
+
|
|
968
|
+
const input: BallotInput = {
|
|
969
|
+
candidates: loaded.map((l) => l.candidate),
|
|
970
|
+
animation,
|
|
971
|
+
version: readVersion(),
|
|
972
|
+
};
|
|
973
|
+
const { html, manifest } = buildBallot(input);
|
|
974
|
+
|
|
975
|
+
console.log('rigc vote');
|
|
976
|
+
console.log(` .. ballot ${manifest.ballot}`);
|
|
977
|
+
console.log(` .. animation ${animation === null ? '(none — the setup pose)' : animation}`);
|
|
978
|
+
for (let i = 0; i < manifest.candidates.length; i++) {
|
|
979
|
+
const entry = manifest.candidates[i];
|
|
980
|
+
const bytes = loaded[i].candidate.pages.reduce((n, p) => n + p.bytes.length, 0);
|
|
981
|
+
console.log(
|
|
982
|
+
` .. ${entry.label} ${entry.digest.slice(0, 'sha256:'.length + 12)}… ` +
|
|
983
|
+
`${entry.pages.length} page(s), ${(bytes / 1024).toFixed(1)} KiB <- ${entry.source}`,
|
|
984
|
+
);
|
|
985
|
+
}
|
|
986
|
+
mkdirSync(dirname(out), { recursive: true });
|
|
987
|
+
writeFileSync(out, html);
|
|
988
|
+
console.log(
|
|
989
|
+
` .. the page shows ${manifest.candidates.map((c) => c.label).join('/')} and nothing else — the paths above are ` +
|
|
990
|
+
'in its manifest, never on the screen',
|
|
991
|
+
);
|
|
992
|
+
console.log(
|
|
993
|
+
` .. embedded every candidate's skeleton, atlas and page(s) as data URIs; the player itself loads from ` +
|
|
994
|
+
`unpkg (@${PLAYER_LINE}), so the first open needs a network`,
|
|
995
|
+
);
|
|
996
|
+
console.log(`rigc: wrote ${out} (${(html.length / 1024).toFixed(1)} KiB — open it in a browser)`);
|
|
997
|
+
console.log(
|
|
998
|
+
`rigc: then record the saved vote with rigc vote --record ${resultFilename(manifest.ballot)} --ballot ${out}`,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
/** vote (record mode) — check one saved vote and append it to the ledger. */
|
|
1003
|
+
function cmdVoteRecord(flags: Record<string, string>): void {
|
|
1004
|
+
for (const key of ['candidate', 'out'] as const) {
|
|
1005
|
+
if (flags[key] !== undefined) {
|
|
1006
|
+
throw new UsageError(`--record and --${key} are the two halves of this command; run them one at a time`);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
const resultPath = resolve(flags.record);
|
|
1010
|
+
const ballotPath = resolve(flags.ballot ?? DEFAULT_BALLOT);
|
|
1011
|
+
const ledgerPath = resolve(flags.ledger ?? DEFAULT_LEDGER);
|
|
1012
|
+
for (const [what, path] of [
|
|
1013
|
+
['result', resultPath],
|
|
1014
|
+
['ballot', ballotPath],
|
|
1015
|
+
] as const) {
|
|
1016
|
+
if (!existsSync(path)) {
|
|
1017
|
+
throw new UsageError(
|
|
1018
|
+
`no ${what} file at ${path}` + (what === 'ballot' ? ' — name the page this vote came from with --ballot' : ''),
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
console.log('rigc vote --record');
|
|
1024
|
+
console.log(` .. result ${resultPath}`);
|
|
1025
|
+
console.log(` .. ballot ${ballotPath}`);
|
|
1026
|
+
console.log(` .. ledger ${ledgerPath}`);
|
|
1027
|
+
|
|
1028
|
+
const manifest = readBallotManifest(readFileSync(ballotPath, 'utf8'), ballotPath);
|
|
1029
|
+
const result = readJsonFile(resultPath);
|
|
1030
|
+
const existing = existsSync(ledgerPath) ? parseLedger(readFileSync(ledgerPath, 'utf8'), ledgerPath) : [];
|
|
1031
|
+
const attempts = existing.filter((l) => l.ballot === manifest.ballot).length;
|
|
1032
|
+
const again = flags.again !== undefined;
|
|
1033
|
+
|
|
1034
|
+
const { refusals, line } = verifyResult(manifest, result, { attempts, again });
|
|
1035
|
+
if (line === null) {
|
|
1036
|
+
for (const refusal of refusals) console.error(` FAIL ${refusal.rule}: ${refusal.detail}`);
|
|
1037
|
+
console.error(`rigc: ${refusals.length} refusal(s) — nothing appended to ${ledgerPath}`);
|
|
1038
|
+
process.exit(1);
|
|
1039
|
+
}
|
|
1040
|
+
for (const rule of VOTE_RULES) console.log(` PASS ${rule}`);
|
|
1041
|
+
|
|
1042
|
+
line.seq = existing.length + 1;
|
|
1043
|
+
mkdirSync(dirname(ledgerPath), { recursive: true });
|
|
1044
|
+
appendFileSync(ledgerPath, ledgerLineText(line));
|
|
1045
|
+
console.log(
|
|
1046
|
+
` .. ${line.choice === TIE ? 'tie' : `winner ${line.choice} = ${line.winner}`}, ` +
|
|
1047
|
+
`reason code ${line.reasonCode}${line.attempt > 1 ? `, attempt ${line.attempt}` : ''}`,
|
|
1048
|
+
);
|
|
1049
|
+
console.log(
|
|
1050
|
+
` .. coverage ${line.coverage.length} candidate(s): ` +
|
|
1051
|
+
line.coverage.map((c) => `${c.label}=${c.digest.slice(0, 'sha256:'.length + 12)}…`).join(' '),
|
|
1052
|
+
);
|
|
1053
|
+
console.log(`rigc: appended line ${line.seq} to ${ledgerPath}`);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
function cmdVote(flags: Record<string, string>, candidates: string[]): void {
|
|
1057
|
+
if (flags.record !== undefined) cmdVoteRecord(flags);
|
|
1058
|
+
else if (candidates.length > 0) cmdVoteBallot(flags, candidates);
|
|
1059
|
+
else {
|
|
1060
|
+
throw new UsageError(
|
|
1061
|
+
'vote takes either 2–4 --candidate <dir | skeleton.json> to write a ballot, or --record <result.json> to ' +
|
|
1062
|
+
'record one that came back',
|
|
1063
|
+
);
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
|
|
729
1067
|
/**
|
|
730
1068
|
* bench — run one rung of the benchmark ladder against a candidate rig.
|
|
731
1069
|
*
|
|
@@ -1012,6 +1350,46 @@ function cmdExplain(flags: Record<string, string>): void {
|
|
|
1012
1350
|
}
|
|
1013
1351
|
}
|
|
1014
1352
|
}
|
|
1353
|
+
// The two constraint groups: one unnamed timeline per constraint, so the
|
|
1354
|
+
// name printed is the constraint's and there is no timeline name to print
|
|
1355
|
+
// beside it. Every field a key carries is shown, because each one is
|
|
1356
|
+
// optional in the file and the ABSENT ones are what a reader has to see —
|
|
1357
|
+
// an omitted `softness` is 0, not "unchanged".
|
|
1358
|
+
for (const group of ['ik', 'transform'] as const) {
|
|
1359
|
+
for (const [name, keys] of Object.entries(anim[group] ?? {})) {
|
|
1360
|
+
console.log(` ${group}.${name} ${keys.length} key(s) <- one timeline per constraint`);
|
|
1361
|
+
for (const key of keys) {
|
|
1362
|
+
const fields = Object.entries(key)
|
|
1363
|
+
.filter(([k]) => k !== 'time' && k !== 'curve')
|
|
1364
|
+
.map(([k, v]) => `${k}=${String(v)}`)
|
|
1365
|
+
.join(' ');
|
|
1366
|
+
const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
|
|
1367
|
+
console.log(` t=${String(key.time).padEnd(7)} ${(fields || '(all defaults)').padEnd(46)} ${curve}`);
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
// Deform timelines are keyed on a skin/slot/attachment triple, and the run
|
|
1372
|
+
// is printed as its span rather than its numbers: `offset` plus a length is
|
|
1373
|
+
// what tells a reader whether the key lands where they meant, and a hundred
|
|
1374
|
+
// vertex offsets on one line tells them nothing.
|
|
1375
|
+
for (const [skinName, slotMap] of Object.entries(anim.attachments ?? {})) {
|
|
1376
|
+
for (const [slotName, attMap] of Object.entries(slotMap)) {
|
|
1377
|
+
for (const [attName, timelines] of Object.entries(attMap)) {
|
|
1378
|
+
for (const [timelineName, keys] of Object.entries(timelines)) {
|
|
1379
|
+
console.log(` ${skinName}/${slotName}/${attName}.${timelineName} ${keys.length} key(s)`);
|
|
1380
|
+
for (const key of keys) {
|
|
1381
|
+
const run = Array.isArray(key.vertices) ? (key.vertices as number[]) : null;
|
|
1382
|
+
const offset = typeof key.offset === 'number' ? key.offset : 0;
|
|
1383
|
+
const span = run
|
|
1384
|
+
? `deform[${offset}..${offset + run.length}] ${run.length / 2} pair(s)`
|
|
1385
|
+
: 'back to the setup pose';
|
|
1386
|
+
const curve = Array.isArray(key.curve) ? `bezier[${key.curve.length}]` : key.curve === 'stepped' ? 'stepped' : 'linear';
|
|
1387
|
+
console.log(` t=${String(key.time).padEnd(7)} ${span.padEnd(46)} ${curve}`);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1015
1393
|
// The draw-order timeline names no target, so it hangs off the animation
|
|
1016
1394
|
// rather than off a slot — and a timeline `explain` did not print would be a
|
|
1017
1395
|
// timeline nobody could check without reading the emitted JSON.
|
|
@@ -1086,8 +1464,18 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1086
1464
|
as: 'the candidate animation to play, when it is named differently from the frame set',
|
|
1087
1465
|
'all-frames': 'print every frame, not just the worst by MAE',
|
|
1088
1466
|
json: 'also write the whole report to this path',
|
|
1467
|
+
frame: 'one pose frame — a picture of the pose to read the part placements out of',
|
|
1468
|
+
scale: `the scale window to search, as frame pixels per part pixel (default \`${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}\`)`,
|
|
1469
|
+
rotation: 'the rotation window to search, in screen degrees (default `-180,180`, a full turn)',
|
|
1470
|
+
'max-residual':
|
|
1471
|
+
`above this residual a placement is refused by name instead of reported flat (default ${DEFAULT_MAX_RESIDUAL}); ` +
|
|
1472
|
+
'it is a reporting threshold, not a pass bar',
|
|
1089
1473
|
animation: 'which animation to show; the default is every one for `render` and the first for `preview`',
|
|
1090
1474
|
max: 'longest side of a rendered frame, in pixels (default 256)',
|
|
1475
|
+
record: 'a saved vote to check against its ballot and append to the ledger, instead of writing a ballot',
|
|
1476
|
+
ballot: `the ballot the --record'd vote answers (default \`${DEFAULT_BALLOT}\`); its embedded manifest is what the vote is checked against`,
|
|
1477
|
+
ledger: `the append-only JSONL the vote lands in (default \`${DEFAULT_LEDGER}\`)`,
|
|
1478
|
+
again: 'record a second vote on a ballot the ledger already has; without it, a repeat is refused rather than doubled',
|
|
1091
1479
|
help: "show this command's flags and exit",
|
|
1092
1480
|
};
|
|
1093
1481
|
|
|
@@ -1109,8 +1497,15 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1109
1497
|
framing: 'per-shot|shared',
|
|
1110
1498
|
as: '<name>',
|
|
1111
1499
|
json: '<out>',
|
|
1500
|
+
frame: '<path>',
|
|
1501
|
+
scale: '<min,max>',
|
|
1502
|
+
rotation: '<min,max>',
|
|
1503
|
+
'max-residual': '<0..1>',
|
|
1112
1504
|
animation: '<name>',
|
|
1113
1505
|
max: '<px>',
|
|
1506
|
+
record: '<result.json>',
|
|
1507
|
+
ballot: '<ballot.html>',
|
|
1508
|
+
ledger: '<votes.jsonl>',
|
|
1114
1509
|
};
|
|
1115
1510
|
|
|
1116
1511
|
interface CommandDoc {
|
|
@@ -1123,12 +1518,14 @@ interface CommandDoc {
|
|
|
1123
1518
|
* Per-command wording for a flag whose value or meaning genuinely differs here.
|
|
1124
1519
|
*
|
|
1125
1520
|
* ⚠️ The default above it — one meaning per flag name, everywhere — is the rule
|
|
1126
|
-
* and this is the named exception to it, not a second table.
|
|
1521
|
+
* and this is the named exception to it, not a second table. Three flags earn it:
|
|
1127
1522
|
* `--out` is a directory of artifacts to `build`, a directory of pictures to
|
|
1128
|
-
* `render` and one file to `preview`; `--fps` is the rate a frame set
|
|
1129
|
-
* RECORDED at to `check`, which reads it off a sidecar, and the rate to
|
|
1130
|
-
* at to `render`, which is choosing it
|
|
1131
|
-
*
|
|
1523
|
+
* `render` and one file to `preview` and `vote`; `--fps` is the rate a frame set
|
|
1524
|
+
* was RECORDED at to `check`, which reads it off a sidecar, and the rate to
|
|
1525
|
+
* SAMPLE at to `render`, which is choosing it; `--candidate` is one artifact
|
|
1526
|
+
* everywhere except `vote`, which is the one command that takes several and is
|
|
1527
|
+
* the reason there is a ballot at all. Writing any of them as one sentence
|
|
1528
|
+
* covering every command would leave every command's own help less true.
|
|
1132
1529
|
*/
|
|
1133
1530
|
overrides?: Record<string, { value?: string; meaning?: string }>;
|
|
1134
1531
|
}
|
|
@@ -1192,6 +1589,43 @@ const COMMANDS: CommandDoc[] = [
|
|
|
1192
1589
|
},
|
|
1193
1590
|
},
|
|
1194
1591
|
},
|
|
1592
|
+
{
|
|
1593
|
+
name: 'pose',
|
|
1594
|
+
usage: [
|
|
1595
|
+
`rigc pose --images <dir> --frame <path> [--scale ${DEFAULT_SCALE_MIN},${DEFAULT_SCALE_MAX}] [--rotation -180,180] [--out ${DEFAULT_POSE_OUT}]`,
|
|
1596
|
+
],
|
|
1597
|
+
flags: ['images', 'frame', 'scale', 'rotation', 'max-residual', 'out'],
|
|
1598
|
+
overrides: {
|
|
1599
|
+
images: { value: '<dir>', meaning: 'the loose part PNGs to place; every `.png` in it is a part, in name order' },
|
|
1600
|
+
out: {
|
|
1601
|
+
value: '<file>',
|
|
1602
|
+
meaning: `the .json report to write (default \`${DEFAULT_POSE_OUT}\`); a directory means "the default name in here"`,
|
|
1603
|
+
},
|
|
1604
|
+
},
|
|
1605
|
+
},
|
|
1606
|
+
{
|
|
1607
|
+
name: 'vote',
|
|
1608
|
+
usage: [
|
|
1609
|
+
`rigc vote --candidate <dir | skeleton.json> --candidate <…> [--candidate …] [--animation <name>] [--out ${DEFAULT_BALLOT}]`,
|
|
1610
|
+
`rigc vote --record <result.json> [--ballot ${DEFAULT_BALLOT}] [--ledger ${DEFAULT_LEDGER}] [--again]`,
|
|
1611
|
+
],
|
|
1612
|
+
flags: ['candidate', 'animation', 'out', 'record', 'ballot', 'ledger', 'again'],
|
|
1613
|
+
overrides: {
|
|
1614
|
+
candidate: {
|
|
1615
|
+
value: '<dir|skeleton.json>',
|
|
1616
|
+
meaning: `repeat it ${MIN_CANDIDATES}–${MAX_CANDIDATES} times — one compiled artifact per pane, labelled A, B, C, D in the order given`,
|
|
1617
|
+
},
|
|
1618
|
+
animation: {
|
|
1619
|
+
meaning:
|
|
1620
|
+
'the one animation every pane plays (default: the first of candidate A). A candidate that does not have ' +
|
|
1621
|
+
'it is refused — two panes playing two animations is not a comparison',
|
|
1622
|
+
},
|
|
1623
|
+
out: {
|
|
1624
|
+
value: '<file>',
|
|
1625
|
+
meaning: `the .html ballot to write (default \`${DEFAULT_BALLOT}\`); a directory means "the default name in here"`,
|
|
1626
|
+
},
|
|
1627
|
+
},
|
|
1628
|
+
},
|
|
1195
1629
|
];
|
|
1196
1630
|
|
|
1197
1631
|
const KNOWN_COMMANDS = COMMANDS.map((c) => c.name);
|
|
@@ -1226,7 +1660,7 @@ const USAGE = [
|
|
|
1226
1660
|
' THE DEFAULT — 20 rules, and the question the output answers when',
|
|
1227
1661
|
' you import it into the Spine editor.',
|
|
1228
1662
|
' spine-html the above, plus this project\'s renderer and archetype policy:',
|
|
1229
|
-
' all
|
|
1663
|
+
' all 36 rules, opt-in. Those extra 14 fire on real, correct,',
|
|
1230
1664
|
' editor-produced Spine data, so they are somebody\'s policy rather',
|
|
1231
1665
|
' than anybody\'s validity.',
|
|
1232
1666
|
'',
|
|
@@ -1246,6 +1680,24 @@ const USAGE = [
|
|
|
1246
1680
|
'draws with rigc\'s own rasteriser; preview embeds the artifact in a page that plays',
|
|
1247
1681
|
'it in the official Spine Web Player, which is also the interop proof.',
|
|
1248
1682
|
'',
|
|
1683
|
+
'pose runs the other way round from everything above: it reads a picture you already',
|
|
1684
|
+
'have — one key pose — and reports where each loose part PNG sits in it (x, y, rotation,',
|
|
1685
|
+
'scale) so an agent can state those poses in a spec by construction:',
|
|
1686
|
+
' rigc pose --images parts/ --frame poseA.png pose.json, one entry per part',
|
|
1687
|
+
'It grades nothing and no pass bar attaches to its numbers. The residual is a trust',
|
|
1688
|
+
'signal, and where two placements are equally good it reports BOTH rather than picking —',
|
|
1689
|
+
'two identical limbs look exactly like that. A part that matches nowhere, a part the',
|
|
1690
|
+
'canvas cannot contain and a part whose rotation is a free degree of freedom are each',
|
|
1691
|
+
'named as such. See `rigc pose --help`.',
|
|
1692
|
+
'',
|
|
1693
|
+
'vote is the same page with two to four builds in it and an answer coming back:',
|
|
1694
|
+
' rigc vote --candidate <build A> --candidate <build B> ballot.html, panes labelled A and B',
|
|
1695
|
+
' rigc vote --record vote-<id>.json --ballot ballot.html check it, append it to votes.jsonl',
|
|
1696
|
+
'Reach for it where the instruments have run out — a choice with no reference behind',
|
|
1697
|
+
'it, two fits that measure the same. The panes carry no paths, a tie is a recorded',
|
|
1698
|
+
'answer rather than a missing one, and a result whose hashes are not the ballot\'s is',
|
|
1699
|
+
'refused by name instead of appended.',
|
|
1700
|
+
'',
|
|
1249
1701
|
'a cuts.json is { "<name>": { "rig": "...", "motion": "...", "out": "...",',
|
|
1250
1702
|
' "manifest": "..." (optional) } }, with every path',
|
|
1251
1703
|
'resolved relative to the cuts.json file itself.',
|
|
@@ -1269,7 +1721,7 @@ try {
|
|
|
1269
1721
|
throw new UsageError(`unknown command: ${command}`);
|
|
1270
1722
|
}
|
|
1271
1723
|
|
|
1272
|
-
const { flags, positional } = parseArgs(rest);
|
|
1724
|
+
const { flags, lists, positional } = parseArgs(rest, REPEATABLE_FLAGS[command]);
|
|
1273
1725
|
if (flags.help !== undefined) {
|
|
1274
1726
|
console.log(commandHelp(command));
|
|
1275
1727
|
process.exit(0);
|
|
@@ -1282,11 +1734,19 @@ try {
|
|
|
1282
1734
|
else if (command === 'bench') cmdBench(flags, positional);
|
|
1283
1735
|
else if (command === 'render') cmdRender(flags);
|
|
1284
1736
|
else if (command === 'preview') cmdPreview(flags);
|
|
1737
|
+
else if (command === 'pose') cmdPose(flags);
|
|
1738
|
+
else if (command === 'vote') cmdVote(flags, lists.candidate ?? []);
|
|
1285
1739
|
} catch (err) {
|
|
1286
1740
|
if (err instanceof UsageError) {
|
|
1287
1741
|
console.error(`rigc: ${err.message}\n\n${USAGE}`);
|
|
1288
1742
|
process.exit(2);
|
|
1289
1743
|
}
|
|
1744
|
+
// A ballot refuses on its arguments, like a usage error, but its messages are
|
|
1745
|
+
// long enough that reprinting the whole usage under them buries the reason.
|
|
1746
|
+
if (err instanceof BallotError) {
|
|
1747
|
+
console.error(`rigc vote: ${err.message}`);
|
|
1748
|
+
process.exit(2);
|
|
1749
|
+
}
|
|
1290
1750
|
if (err instanceof CompileError) {
|
|
1291
1751
|
console.error(`rigc compile error: ${err.message}`);
|
|
1292
1752
|
process.exit(1);
|
|
@@ -1295,5 +1755,12 @@ try {
|
|
|
1295
1755
|
console.error(`rigc check error: ${err.message}`);
|
|
1296
1756
|
process.exit(1);
|
|
1297
1757
|
}
|
|
1758
|
+
// Like a usage error in kind — a missing directory, an unreadable frame — but
|
|
1759
|
+
// its messages name a path and a reason, and reprinting the whole usage under
|
|
1760
|
+
// them buries that.
|
|
1761
|
+
if (err instanceof PoseError) {
|
|
1762
|
+
console.error(`rigc pose: ${err.message}`);
|
|
1763
|
+
process.exit(2);
|
|
1764
|
+
}
|
|
1298
1765
|
throw err;
|
|
1299
1766
|
}
|