spine-rigc 1.9.4 → 2.0.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/plugin.json +1 -1
- package/NOTICE.md +37 -21
- package/README.md +38 -20
- package/bin/rigc.cjs +45 -2
- package/cli_core.ts +35 -0
- package/docs/AUTHORING.md +24 -12
- package/package.json +4 -5
- package/skills/rigc/SKILL.md +8 -5
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/emitted/index.ts +119 -0
- package/src/assertions/report.ts +64 -0
- package/src/cli/core_commands.ts +82 -7
- package/src/cli/shared.ts +567 -59
- package/src/cli/spine_commands.ts +36 -366
- package/src/validate.ts +16 -86
|
@@ -8,24 +8,20 @@
|
|
|
8
8
|
* module (`SPINE_COMMAND_RUNS`); an entry that does not links nothing of the
|
|
9
9
|
* runtime, and refuses these commands naming what each needs it for.
|
|
10
10
|
*/
|
|
11
|
-
import { type AtlasRegion, DEFAULT_PADDING, DEFAULT_PAGE_EDGES, DEFAULT_PAGE_SIZE, packAtlas, PAGE_EDGES, type PageEdges, pageFootprint } from '../atlas.ts';
|
|
12
11
|
import { type BallotCandidateInput, type BallotInput, buildBallot, ledgerLineText, MAX_CANDIDATES, MIN_CANDIDATES, parseLedger, readBallotManifest, resultFilename, TIE, verifyResult, VOTE_RULES } from '../ballot.ts';
|
|
13
12
|
import { boneDistance, boneDistLines, type BoneDistReport } from '../bonedist.ts';
|
|
14
13
|
import { IDENTITY_CORRESPONDENCE } from '../correspondence.ts';
|
|
15
14
|
import { checkLines, type CheckReport } from '../check.ts';
|
|
16
|
-
import { compile
|
|
15
|
+
import { compile } from '../compile.ts';
|
|
17
16
|
import { diffLines, type DiffReport, diffSkeletons, reportedFigures, sectionFigures } from '../diff.ts';
|
|
18
|
-
import { copyAtlasPages, plannedPageCopies } from '../emit.ts';
|
|
19
17
|
import { findRung, RUNG_IDS, type RungSkeleton } from '../ladder.ts';
|
|
20
|
-
import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
|
|
21
18
|
import { buildPreview, buildPreviewPanes, PLAYER_LINE, type PreviewGate, type PreviewInput, type PreviewPage } from '../preview.ts';
|
|
22
19
|
import { atlasPageNames } from '../render.ts';
|
|
23
20
|
import { BoneDistError } from '../bonedist.ts';
|
|
24
|
-
import { type
|
|
25
|
-
import {
|
|
26
|
-
import { type CliRefusal, type CommandRun, DEFAULT_BALLOT, dropLine, PACKAGE_ROOT, DEFAULT_LEDGER, meshBudget, meshDepthNote, meshFit, meshInfluenceNote, parseJsonNamed, readAnimationFlag, readJsonFile, readPackageMeta, readSkeletonText, readVersion, resolveArtifacts, resolveCut, resolveViewable, runCheck, STAGELESS_FRAMING, UsageError, writeJson } from './shared.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 } from './shared.ts';
|
|
27
23
|
import { appendFileSync, existsSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs';
|
|
28
|
-
import {
|
|
24
|
+
import { dirname, join, resolve } from 'node:path';
|
|
29
25
|
|
|
30
26
|
|
|
31
27
|
function repositoryUrl(): string {
|
|
@@ -39,358 +35,36 @@ function repositoryUrl(): string {
|
|
|
39
35
|
// ---------------------------------------------------------------------------
|
|
40
36
|
|
|
41
37
|
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* matters in both directions: a typo used to re-apply the strictest rulebook to
|
|
46
|
-
* data the caller was trying to exempt, and it would now drop the policy layer
|
|
47
|
-
* from a caller who typed `--profile spine-htlm` and believes they asked for it.
|
|
48
|
-
* Neither is something to discover from a green.
|
|
49
|
-
*/
|
|
50
|
-
function readProfile(flags: Record<string, string>): ValidateProfile {
|
|
51
|
-
const raw = flags.profile;
|
|
52
|
-
if (raw === undefined) return CLI_DEFAULT_PROFILE;
|
|
53
|
-
const found = VALIDATE_PROFILES.find((p) => p === raw);
|
|
54
|
-
if (!found) throw new UsageError(`--profile ${JSON.stringify(raw)}; known profiles: ${VALIDATE_PROFILES.join(', ')}`);
|
|
55
|
-
return found;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
/**
|
|
59
|
-
* An atlas text to gate INSTEAD of the compile's own, with the second, independent
|
|
60
|
-
* emit A18 compares it against.
|
|
61
|
-
*
|
|
62
|
-
* `--pack` is the only caller. A packed build is gated twice on purpose — once as
|
|
63
|
-
* compiled (which is the gate that reads the loose PNGs, so `A06`'s size-vs-file
|
|
64
|
-
* clause still measures the art R5 measures) and once as packed (which is the pair
|
|
65
|
-
* that actually ships). Handing the second pass its texts rather than re-deriving
|
|
66
|
-
* them here keeps `runGate` ignorant of what a pack is.
|
|
67
|
-
*/
|
|
68
|
-
interface AtlasOverride {
|
|
69
|
-
text: string;
|
|
70
|
-
again: string;
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function runGate(
|
|
74
|
-
result: CompileResult,
|
|
75
|
-
modelText: string,
|
|
76
|
-
opts: CompileOptions,
|
|
77
|
-
profile: ValidateProfile,
|
|
78
|
-
/** The atlas text `build` writes for a compile's own — `--copy-images` renames the pages — which the second compile's document is spelled from, as `modelText` was (issue #1016). */
|
|
79
|
-
written: (atlasText: string) => string,
|
|
80
|
-
atlas?: AtlasOverride,
|
|
81
|
-
): number {
|
|
82
|
-
// The determinism check compares a second, independent compile — its model
|
|
83
|
-
// document included, which is the text `build` writes beside the pair, and
|
|
84
|
-
// which states where each region sits in the atlas written with it (`pages`,
|
|
85
|
-
// issue #1016): so the second document is spelled from the second compile's
|
|
86
|
-
// atlas as it would be written, or from the second, independent pack.
|
|
87
|
-
const again = compile(opts);
|
|
88
|
-
const report = validate({
|
|
89
|
-
skeletonText: result.skeletonText,
|
|
90
|
-
atlasText: atlas ? atlas.text : result.atlasText,
|
|
91
|
-
atlasDir: opts.outDir,
|
|
92
|
-
declaredDurations: result.declaredDurations,
|
|
93
|
-
modelText,
|
|
94
|
-
reEmit: { skeletonText: again.skeletonText, atlasText: atlas ? atlas.again : again.atlasText, modelText: modelDocument(again.model, again.skeletonText, atlas ? atlas.again : written(again.atlasText)) },
|
|
95
|
-
rig: result.rig,
|
|
96
|
-
profile,
|
|
97
|
-
});
|
|
98
|
-
for (const line of reportLines(report)) console.log(line);
|
|
99
|
-
console.log(
|
|
100
|
-
` .. ${Object.entries(report.stats)
|
|
101
|
-
.map(([k, v]) => `${k}=${v}`)
|
|
102
|
-
.join(' ')}`,
|
|
103
|
-
);
|
|
104
|
-
return report.failures.length;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* Read one non-negative integer flag, or its default.
|
|
109
|
-
*
|
|
110
|
-
* A usage error rather than a `NaN` that reaches the packer: `--padding two`
|
|
111
|
-
* would otherwise place every region at NaN and write a blank page, which is a
|
|
112
|
-
* green build and an empty picture.
|
|
38
|
+
* `cli.ts build`'s gate (issue #1060, `BuildGate`): the round trip through
|
|
39
|
+
* spine-core and every named assertion — the gate `build` has always run,
|
|
40
|
+
* with the arguments it has always been handed, and the same lines.
|
|
113
41
|
*/
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
function readPageEdges(flags: Record<string, string>): PageEdges {
|
|
129
|
-
const raw = flags['page-edges'];
|
|
130
|
-
if (raw === undefined) return DEFAULT_PAGE_EDGES;
|
|
131
|
-
const found = PAGE_EDGES.find((e) => e === raw);
|
|
132
|
-
if (!found) throw new UsageError(`--page-edges ${JSON.stringify(raw)}; known values: ${PAGE_EDGES.join(', ')}`);
|
|
133
|
-
return found;
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
/**
|
|
137
|
-
* The rectangle a region occupies **on its page**, for a line that has already
|
|
138
|
-
* said where the region is — and the empty string where the page rectangle is
|
|
139
|
-
* the one `bounds:` already states.
|
|
140
|
-
*
|
|
141
|
-
* ## The fact no surface an author reads carried (issue #718)
|
|
142
|
-
*
|
|
143
|
-
* The atlas line beside this clause prints the DRAWING's size, because that is
|
|
144
|
-
* what an attachment's width and height mean. A packer that turned the drawing a
|
|
145
|
-
* quarter to fit it wrote `bounds:` in the drawing's orientation too. So an
|
|
146
|
-
* author holding the pack and the build report had neither end of the rectangle
|
|
147
|
-
* they have to cut out of the page to measure a part against a rendered frame —
|
|
148
|
-
* and the one place rigc printed it was `A06`'s overlap text, reachable only
|
|
149
|
-
* under `--profile spine-html`. The knowledge was in the tree the whole time:
|
|
150
|
-
* `pageFootprint` has derived this rectangle for every reader of it since issue
|
|
151
|
-
* #579, and nothing an author reads said it.
|
|
152
|
-
*
|
|
153
|
-
* ⚠️ **The condition is `pageFootprint`'s own answer, not a second reading of
|
|
154
|
-
* `degrees`.** Re-spelling that predicate here is the exact duplication #579 was
|
|
155
|
-
* filed on — four readers derived this rectangle and two derived it wrongly — so
|
|
156
|
-
* the clause asks the function whether its answer differs from the `bounds:`
|
|
157
|
-
* line, and prints only then.
|
|
158
|
-
*
|
|
159
|
-
* 🔸 A consequence worth stating rather than leaving to be discovered: a region
|
|
160
|
-
* whose KEPT rectangle is square is silent here, because a quarter turn leaves
|
|
161
|
-
* its footprint the same two numbers and there is nothing the pack does not
|
|
162
|
-
* already say. The general rule — `bounds` is the unturned size, the footprint
|
|
163
|
-
* is its transpose at `rotate: 90` and `rotate: 270`, and which way to turn the
|
|
164
|
-
* rectangle to recover the drawing — belongs to an author's own reading and is
|
|
165
|
-
* stated in `docs/AUTHORING.md` §0.2, which holds for every region including
|
|
166
|
-
* that one.
|
|
167
|
-
*/
|
|
168
|
-
function pageRectangle(region: AtlasRegion): string {
|
|
169
|
-
const foot = pageFootprint(region);
|
|
170
|
-
return foot.width === region.width && foot.height === region.height
|
|
171
|
-
? ''
|
|
172
|
-
: `, occupies ${foot.width}x${foot.height}`;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
export function cmdBuild(flags: Record<string, string>): void {
|
|
176
|
-
const { label, opts } = resolveCut(flags);
|
|
177
|
-
const profile = readProfile(flags);
|
|
178
|
-
const packing = flags.pack !== undefined;
|
|
179
|
-
// Two combinations are refused rather than silently resolved, because in each
|
|
180
|
-
// one the two flags disagree about a single question and there is no answer
|
|
181
|
-
// that is not a guess about which the caller meant. (There were three until
|
|
182
|
-
// issue #266 — see the note below the second.)
|
|
183
|
-
if (packing && opts.atlasInPath !== undefined) {
|
|
184
|
-
throw new UsageError(
|
|
185
|
-
'--pack and --atlas-in are opposite directions through the same door: --pack MAKES an atlas out of the ' +
|
|
186
|
-
'loose parts, --atlas-in resolves the parts against one somebody already made. Pick one',
|
|
187
|
-
);
|
|
188
|
-
}
|
|
189
|
-
if (packing && flags['copy-images'] !== undefined) {
|
|
190
|
-
throw new UsageError(
|
|
191
|
-
'--pack already writes self-contained pages into --out (that is what packing is), and --copy-images copies ' +
|
|
192
|
-
'the loose part PNGs, which a packed atlas does not reference. Drop --copy-images',
|
|
193
|
-
);
|
|
194
|
-
}
|
|
195
|
-
// The copy itself happens after the gate (below). The header has to know NOW,
|
|
196
|
-
// because the skeleton text the gate reads is the skeleton text that is written
|
|
197
|
-
// — `skeleton.images` says where the parts will be (issue #370).
|
|
198
|
-
if (flags['copy-images'] !== undefined) opts.copyImages = true;
|
|
199
|
-
// `--pack --profile spine-html` used to be the third refusal here, because
|
|
200
|
-
// A06's coverage clause was "one part per page" flat and a legitimate pack
|
|
201
|
-
// arrived at the gate reading as a defect. Since issue #266's second follow-up
|
|
202
|
-
// that clause is "one part per page OR a tiling page", so the combination is
|
|
203
|
-
// now a build like any other — and it is the only one that puts the renderer's
|
|
204
|
-
// own rulebook over shared-page sampling.
|
|
205
|
-
if (!packing) {
|
|
206
|
-
for (const name of ['page-size', 'padding', 'page-edges'] as const) {
|
|
207
|
-
if (flags[name] !== undefined) throw new UsageError(`--${name} only means something with --pack`);
|
|
208
|
-
}
|
|
209
|
-
}
|
|
210
|
-
console.log(`rigc build ${label}`);
|
|
211
|
-
// Named explicitly and on their own lines rather than folded into the header
|
|
212
|
-
// above: with two input files, a header that names only one of them (the rig,
|
|
213
|
-
// historically) reads as though it were the one at fault whenever the error
|
|
214
|
-
// that follows actually comes from the other.
|
|
215
|
-
console.log(` .. rig ${opts.rigPath}`);
|
|
216
|
-
console.log(` .. motion ${opts.motionPath}`);
|
|
217
|
-
const result = compile(opts);
|
|
218
|
-
|
|
219
|
-
if (opts.atlasInPath !== undefined) console.log(` .. atlas-in ${opts.atlasInPath}`);
|
|
220
|
-
console.log(` .. ${result.images.length} part page(s):`);
|
|
221
|
-
for (const img of result.images) {
|
|
222
|
-
// An imported part says where on the page it came from, because "resolved
|
|
223
|
-
// against a region" is the claim `--atlas-in` makes and a line that only
|
|
224
|
-
// repeated the page filename would look identical for all of them. A page
|
|
225
|
-
// that declares a `scale:` also says so and shows the texels it was read
|
|
226
|
-
// from: the size on the left is the DRAWING's and the rectangle is the
|
|
227
|
-
// pack's, and issue #267 is the report that printed the second as the first.
|
|
228
|
-
//
|
|
229
|
-
// `pageRectangle` closes the line's last silence (issue #718), and it is
|
|
230
|
-
// placed LAST rather than beside the turn it follows from, which is where
|
|
231
|
-
// the card put it. The two clauses collide nowhere else, and the collision
|
|
232
|
-
// is real: `scale 0.5 (373x106 texels)` is itself a size, so
|
|
233
|
-
// `rotate 90, occupies 106x373 scale 0.5 (…)` reads as though the footprint
|
|
234
|
-
// were the scaled quantity. As a trailing clause of the whole location
|
|
235
|
-
// phrase it is unambiguous with a `scale:` line and identical to the card's
|
|
236
|
-
// wording without one, which is every pack that has no `scale:` to state.
|
|
237
|
-
const where =
|
|
238
|
-
img.atlas === undefined
|
|
239
|
-
? img.page
|
|
240
|
-
: `${img.page} @ ${img.atlas.x},${img.atlas.y}${img.atlas.degrees ? ` rotate ${img.atlas.degrees}` : ''}` +
|
|
241
|
-
(img.atlasScale === undefined
|
|
242
|
-
? ''
|
|
243
|
-
: ` scale ${img.atlasScale} (${img.atlas.originalWidth}x${img.atlas.originalHeight} texels)`) +
|
|
244
|
-
pageRectangle(img.atlas);
|
|
245
|
-
console.log(` .. ${img.region.padEnd(24)} ${img.width}x${img.height} <- ${where}`);
|
|
246
|
-
}
|
|
247
|
-
for (const d of result.droppedStates) console.log(dropLine(d));
|
|
248
|
-
// "The optional slots are optional" is a claim about this code path, so this
|
|
249
|
-
// code path says which ones it left out rather than being silently right.
|
|
250
|
-
for (const a of result.absentParts) {
|
|
251
|
-
console.log(` ABSENT ${a.slot}: ${a.why} — slot not emitted`);
|
|
252
|
-
}
|
|
253
|
-
for (const m of result.meshes) {
|
|
254
|
-
console.log(
|
|
255
|
-
` MESH ${m.slot.padEnd(12)} ${m.kind.padEnd(8)} ${m.vertices} vertices / ${m.triangles} triangles ` +
|
|
256
|
-
`${meshBudget(result.rig)} bones=[${m.bones.join(', ')}] attachments=[${m.attachments.join(', ')}]${meshFit(m)}` +
|
|
257
|
-
meshDepthNote(m) +
|
|
258
|
-
meshInfluenceNote(m),
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
for (const ph of result.physics) {
|
|
42
|
+
const ROUND_TRIP_GATE: BuildGate = {
|
|
43
|
+
heading: (profile) => ` .. validate (spine-core round trip + machine assertions, profile ${profile})`,
|
|
44
|
+
run: ({ result, atlasText, atlasDir, modelText, reEmit, profile }) => {
|
|
45
|
+
const report = validate({
|
|
46
|
+
skeletonText: result.skeletonText,
|
|
47
|
+
atlasText,
|
|
48
|
+
atlasDir,
|
|
49
|
+
declaredDurations: result.declaredDurations,
|
|
50
|
+
modelText,
|
|
51
|
+
reEmit,
|
|
52
|
+
rig: result.rig,
|
|
53
|
+
profile,
|
|
54
|
+
});
|
|
55
|
+
for (const line of reportLines(report)) console.log(line);
|
|
262
56
|
console.log(
|
|
263
|
-
`
|
|
264
|
-
|
|
57
|
+
` .. ${Object.entries(report.stats)
|
|
58
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
59
|
+
.join(' ')}`,
|
|
265
60
|
);
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
// before anything is (issue #922). It states where each region sits in the
|
|
271
|
-
// atlas written beside it (`pages`, issue #1016), so it is spelled from that
|
|
272
|
-
// atlas: under `--copy-images` the text with the copies' page names, planned
|
|
273
|
-
// here from the text alone (`plannedPageCopies`) and copied after the gate.
|
|
274
|
-
// Under `--pack` the pages move again after this gate, and the document
|
|
275
|
-
// written is the one the packed gate below spells and compares.
|
|
276
|
-
const copying = flags['copy-images'] !== undefined;
|
|
277
|
-
const writtenAtlas = (atlasText: string): string => (copying ? plannedPageCopies(atlasText, opts.outDir).atlasText : atlasText);
|
|
278
|
-
let modelText = modelDocument(result.model, result.skeletonText, writtenAtlas(result.atlasText));
|
|
279
|
-
console.log(` .. validate (spine-core round trip + machine assertions, profile ${profile})`);
|
|
280
|
-
const failures = runGate(result, modelText, opts, profile, writtenAtlas);
|
|
281
|
-
if (failures > 0) {
|
|
282
|
-
console.error(`rigc: ${failures} assertion(s) failed — nothing written`);
|
|
283
|
-
process.exit(1);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
mkdirSync(opts.outDir, { recursive: true });
|
|
287
|
-
|
|
288
|
-
// `--copy-images`: `--out` is otherwise NOT self-contained — a page's default
|
|
289
|
-
// path is relative to the source art (often `../parts/foo.png`), which is
|
|
290
|
-
// correct for a build sitting beside the project it came from and breaks the
|
|
291
|
-
// moment the directory is zipped, committed or moved on its own (issue #217).
|
|
292
|
-
// Opt-in only: the default stays exactly what it has always been.
|
|
293
|
-
//
|
|
294
|
-
// What is copied is what the ATLAS names, not what the image list holds: under
|
|
295
|
-
// `--atlas-in` the two are different lists, and rebuilding the text from the
|
|
296
|
-
// second wrote a file the pack never contained — zero bytes for a rig that
|
|
297
|
-
// declares no parts, one fabricated page per part for a rig that does, both of
|
|
298
|
-
// them green here because the gate above had already read the compile's own
|
|
299
|
-
// text (issue #693, `src/emit.ts`).
|
|
300
|
-
let atlasText = result.atlasText;
|
|
301
|
-
if (flags['copy-images'] !== undefined) {
|
|
302
|
-
const copied = copyAtlasPages(atlasText, opts.outDir);
|
|
303
|
-
// The document's pages were spelled from the plan; the copy is held to it before anything of the pair is written.
|
|
304
|
-
if (copied.atlasText !== writtenAtlas(result.atlasText)) {
|
|
305
|
-
throw new Error(`internal: --copy-images wrote an atlas whose page names are not the ones ${MODEL_DOCUMENT_FILE} was spelled with — nothing of the pair was written`);
|
|
306
|
-
}
|
|
307
|
-
atlasText = copied.atlasText;
|
|
308
|
-
console.log(` .. copy-images: ${copied.pages.length} page(s) copied into ${opts.outDir}`);
|
|
309
|
-
for (const p of copied.pages) {
|
|
310
|
-
const note = p.to === basename(p.from) ? '' : ' (renamed — basename collision)';
|
|
311
|
-
console.log(` .. ${p.to.padEnd(24)} <- ${p.from} (${p.regions} region(s))${note}`);
|
|
312
|
-
}
|
|
313
|
-
}
|
|
314
|
-
|
|
315
|
-
// `--pack`: the parts go onto shared pages, which are written here as real
|
|
316
|
-
// PNGs, so `--out` is self-contained by construction. The atlas above stays
|
|
317
|
-
// the one the gate just read — packing changes only the ARRANGEMENT of the
|
|
318
|
-
// bytes, and the sizes in `result.images` are still the ones measured off the
|
|
319
|
-
// loose PNGs (see src/atlas.ts's header).
|
|
320
|
-
if (packing) {
|
|
321
|
-
const packOpts = {
|
|
322
|
-
pageSize: readIntFlag(flags, 'page-size', DEFAULT_PAGE_SIZE),
|
|
323
|
-
padding: readIntFlag(flags, 'padding', DEFAULT_PADDING),
|
|
324
|
-
pageEdges: readPageEdges(flags),
|
|
325
|
-
pageStem: 'skeleton',
|
|
326
|
-
};
|
|
327
|
-
const inputs = result.images.map((img) => ({
|
|
328
|
-
region: img.region,
|
|
329
|
-
absPath: img.absPath,
|
|
330
|
-
width: img.width,
|
|
331
|
-
height: img.height,
|
|
332
|
-
}));
|
|
333
|
-
const packed = packAtlas(inputs, packOpts);
|
|
334
|
-
atlasText = packed.atlasText;
|
|
335
|
-
for (const page of packed.pages) {
|
|
336
|
-
page.plate.writePng(join(opts.outDir, page.name));
|
|
337
|
-
console.log(
|
|
338
|
-
` .. pack: ${page.name} ${page.width}x${page.height}, ` +
|
|
339
|
-
`${packed.placements.filter((p) => packed.pages[p.page].name === page.name).length} region(s), ` +
|
|
340
|
-
`${(page.occupancy * 100).toFixed(1)}% covered, padding ${packed.padding}` +
|
|
341
|
-
(packOpts.pageEdges === 'free' ? ', page edges free' : ''),
|
|
342
|
-
);
|
|
343
|
-
}
|
|
344
|
-
for (const place of packed.placements) {
|
|
345
|
-
console.log(
|
|
346
|
-
` .. ${place.region.padEnd(24)} ${place.width}x${place.height} -> ` +
|
|
347
|
-
`${packed.pages[place.page].name} @ ${place.x},${place.y}`,
|
|
348
|
-
);
|
|
349
|
-
}
|
|
350
|
-
// The pages are on disk now, so the packed pair can be gated as an artifact
|
|
351
|
-
// rather than trusted as a construction: A17 stats every page, A06 reads its
|
|
352
|
-
// IHDR back, A07 re-reads the text shape, A08 re-joins every attachment onto
|
|
353
|
-
// a region, and A18 compares a second independent compile+pack. Two gates on
|
|
354
|
-
// one build is the cost of shipping a second atlas shape.
|
|
355
|
-
console.log(' .. validate (packed atlas, pages on disk)');
|
|
356
|
-
const packAgain = packAtlas(
|
|
357
|
-
compile(opts).images.map((img) => ({
|
|
358
|
-
region: img.region,
|
|
359
|
-
absPath: img.absPath,
|
|
360
|
-
width: img.width,
|
|
361
|
-
height: img.height,
|
|
362
|
-
})),
|
|
363
|
-
packOpts,
|
|
364
|
-
);
|
|
365
|
-
// The document written is spelled from the packed atlas, and this gate's A18 compares it with a
|
|
366
|
-
// second compile's spelled from the second, independent pack (issue #1016).
|
|
367
|
-
modelText = modelDocument(result.model, result.skeletonText, atlasText);
|
|
368
|
-
const packFailures = runGate(result, modelText, opts, profile, (text) => text, { text: atlasText, again: packAgain.atlasText });
|
|
369
|
-
if (packFailures > 0) {
|
|
370
|
-
console.error(
|
|
371
|
-
`rigc: ${packFailures} assertion(s) failed on the PACKED atlas — the pages were written to ` +
|
|
372
|
-
`${opts.outDir}, the skeleton/atlas pair was not`,
|
|
373
|
-
);
|
|
374
|
-
process.exit(1);
|
|
375
|
-
}
|
|
376
|
-
}
|
|
61
|
+
return report.failures.length;
|
|
62
|
+
},
|
|
63
|
+
look: (outDir) => `rigc: look at it: rigc preview --candidate ${outDir}`,
|
|
64
|
+
};
|
|
377
65
|
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
// rigc's own record of the compiled rig (`rigc-compiled/3`, issue #922;
|
|
381
|
-
// its `pages` the atlas just written, issue #1016), written with the pair
|
|
382
|
-
// and only after the same gate — under `--pack`, the packed one. rigc's own posing core
|
|
383
|
-
// reads it (`readModel`, `src/core/index.ts`, issue #380's step 2).
|
|
384
|
-
writeFileSync(join(opts.outDir, MODEL_DOCUMENT_FILE), modelText);
|
|
385
|
-
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.json')}`);
|
|
386
|
-
console.log(`rigc: wrote ${join(opts.outDir, 'skeleton.atlas')}`);
|
|
387
|
-
console.log(`rigc: wrote ${join(opts.outDir, MODEL_DOCUMENT_FILE)}`);
|
|
388
|
-
// The next command is part of the message (issue #837). A green build is the
|
|
389
|
-
// moment somebody wants to see what came out, and the one page rigc writes
|
|
390
|
-
// for that is `preview` of exactly this directory — so the line names it,
|
|
391
|
-
// with the path already resolved. Printed only here: a red build wrote
|
|
392
|
-
// nothing, so there is nothing to look at and no line.
|
|
393
|
-
console.log(`rigc: look at it: rigc preview --candidate ${opts.outDir}`);
|
|
66
|
+
export function cmdBuild(flags: Record<string, string>): void {
|
|
67
|
+
runBuild(flags, ROUND_TRIP_GATE);
|
|
394
68
|
}
|
|
395
69
|
|
|
396
70
|
export function cmdValidate(flags: Record<string, string>, positional: string[]): void {
|
|
@@ -400,7 +74,9 @@ export function cmdValidate(flags: Record<string, string>, positional: string[])
|
|
|
400
74
|
const named = flags.cut !== undefined || flags.rig !== undefined;
|
|
401
75
|
const profile = readProfile(flags);
|
|
402
76
|
const derivedOpts = named ? resolveCut(flags).opts : null;
|
|
403
|
-
|
|
77
|
+
// Both files there before the first line, from the one statement of a build (issue #1046): a directory without them
|
|
78
|
+
// read the skeleton unguarded and died on an ENOENT and a stack.
|
|
79
|
+
const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas));
|
|
404
80
|
console.log(`rigc validate ${skeletonPath}`);
|
|
405
81
|
console.log(` .. atlas ${atlasPath}`);
|
|
406
82
|
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
@@ -509,11 +185,8 @@ export function cmdPreview(flags: Record<string, string>, candidates: string[]):
|
|
|
509
185
|
}
|
|
510
186
|
const found = several
|
|
511
187
|
? candidates.map((target) => {
|
|
512
|
-
const { skeletonPath, atlasPath } =
|
|
513
|
-
|
|
514
|
-
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
515
|
-
}
|
|
516
|
-
return { target, skeletonPath, atlasPath, atlasDir: dirname(atlasPath) };
|
|
188
|
+
const { skeletonPath, atlasPath, atlasDir } = spinePairOf(resolveBuild(target, undefined));
|
|
189
|
+
return { target, skeletonPath, atlasPath, atlasDir };
|
|
517
190
|
})
|
|
518
191
|
: [{ target: flags.candidate, ...resolveViewable(flags) }];
|
|
519
192
|
// ⚠️ By the FILE each one resolves to, not by the text typed: `build/` and
|
|
@@ -616,10 +289,7 @@ export function cmdPreview(flags: Record<string, string>, candidates: string[]):
|
|
|
616
289
|
|
|
617
290
|
/** Load one candidate off disk in the shape a ballot needs. */
|
|
618
291
|
function loadBallotCandidate(target: string): { candidate: BallotCandidateInput; animations: string[] } {
|
|
619
|
-
const { skeletonPath, atlasPath } =
|
|
620
|
-
for (const path of [skeletonPath, atlasPath]) {
|
|
621
|
-
if (!existsSync(path)) throw new UsageError(`nothing at ${path}`);
|
|
622
|
-
}
|
|
292
|
+
const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(target, undefined));
|
|
623
293
|
const skeletonText = readFileSync(skeletonPath, 'utf8');
|
|
624
294
|
const atlasText = readFileSync(atlasPath, 'utf8');
|
|
625
295
|
const atlasDir = dirname(atlasPath);
|
package/src/validate.ts
CHANGED
|
@@ -85,6 +85,7 @@ import type { SlotColourFacts, SlotTimelines } from './assertions/facts/slot_col
|
|
|
85
85
|
import { a03RegionWidthHeightFinite } from './assertions/bodies/a03.ts';
|
|
86
86
|
import { a11NoClippingAttachments } from './assertions/bodies/a11.ts';
|
|
87
87
|
import { a17AtlasPageFilesExist } from './assertions/bodies/a17.ts';
|
|
88
|
+
import { a18DeterministicEmit } from './assertions/bodies/a18.ts';
|
|
88
89
|
import { a45SeparableColorTimelinesOwnTheirChannelsAndPoseAsWritten } from './assertions/bodies/a45.ts';
|
|
89
90
|
import { attachmentRegionLookups, type AttachmentRegionJoin } from './assertions/region_lookups.ts';
|
|
90
91
|
import { attachmentRegionJoins } from './region_joins.ts';
|
|
@@ -182,20 +183,9 @@ export type { Failure } from './assertions/harness.ts';
|
|
|
182
183
|
*/
|
|
183
184
|
export type ValidateProfile = AssertionProfile;
|
|
184
185
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
* What the CLI uses when `--profile` is absent — and ONLY the CLI. This is not
|
|
189
|
-
* `validate()`'s default; that function has none (above).
|
|
190
|
-
*
|
|
191
|
-
* `spine` since issue #221. The published package's pitch is "the output imports
|
|
192
|
-
* into the Spine editor", which is exactly the question `spine` asks, and a
|
|
193
|
-
* stranger's first build was being judged instead against one renderer's policy
|
|
194
|
-
* and one project's canvas budget — 14 rules they have no stake in, with the
|
|
195
|
-
* escape hatch documented only in prose. Defaults beat prose. `spine-html` is
|
|
196
|
-
* still one flag away, and every report names the profile that judged it.
|
|
197
|
-
*/
|
|
198
|
-
export const CLI_DEFAULT_PROFILE: ValidateProfile = 'spine';
|
|
186
|
+
// The profiles, the CLI's default and the report printer — `./assertions/report.ts` since issue #1060, so the
|
|
187
|
+
// entry that links none of spine-core gates and prints with them; every one is re-exported from here.
|
|
188
|
+
export { CLI_DEFAULT_PROFILE, reportLines, VALIDATE_PROFILES } from './assertions/report.ts';
|
|
199
189
|
|
|
200
190
|
// What kind of rule each assertion is — `./assertions/kinds.ts` since issue
|
|
201
191
|
// #1025, where the model side's harness reads the same table.
|
|
@@ -842,19 +832,6 @@ export function timelineAddCells(data: ReturnType<SkeletonJson['readSkeletonData
|
|
|
842
832
|
return cells;
|
|
843
833
|
}
|
|
844
834
|
|
|
845
|
-
/**
|
|
846
|
-
* Where two texts first differ, as A18 names it: the 1-based line and each
|
|
847
|
-
* side's line, trimmed and cut to 120 characters.
|
|
848
|
-
*/
|
|
849
|
-
function firstDifferingLine(first: string, second: string): string {
|
|
850
|
-
const a = first.split('\n');
|
|
851
|
-
const b = second.split('\n');
|
|
852
|
-
let i = 0;
|
|
853
|
-
while (i < a.length && i < b.length && a[i] === b[i]) i += 1;
|
|
854
|
-
const cut = (line: string | undefined): string => (line === undefined ? '(end of text)' : JSON.stringify(line.trim().slice(0, 120)));
|
|
855
|
-
return `${i + 1}: ${cut(a[i])} first, ${cut(b[i])} second`;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
835
|
/**
|
|
859
836
|
* The runtime's supply of `SkinEntryFacts` (issue #1025): every skin of the
|
|
860
837
|
* loaded skeleton in the loaded order, and each skin's entries as
|
|
@@ -3348,28 +3325,19 @@ export function validate(input: ValidateInput): ValidateReport {
|
|
|
3348
3325
|
check('A30_STROKE_WITHIN_CAP_CONTAINMENT', () => a30StrokeWithinCapContainment(verdicts, rawBoneTimelines(raw), input));
|
|
3349
3326
|
|
|
3350
3327
|
// --- A18: determinism ----------------------------------------------------
|
|
3328
|
+
// The body is `./assertions/bodies/a18.ts` (issue #1060): the model document's clause, which the model side
|
|
3329
|
+
// runs too, and the Spine pair's two clauses handed to it as this side's encoding findings, in their order.
|
|
3330
|
+
// Same vacuous-pass trap as A09: re-gating artifacts already on disk hands this assertion no second compile.
|
|
3351
3331
|
check('A18_DETERMINISTIC_EMIT', () => {
|
|
3352
|
-
|
|
3353
|
-
|
|
3354
|
-
|
|
3355
|
-
|
|
3356
|
-
|
|
3357
|
-
|
|
3358
|
-
|
|
3359
|
-
|
|
3360
|
-
}
|
|
3361
|
-
if (input.reEmit.atlasText !== input.atlasText) {
|
|
3362
|
-
fail('A18_DETERMINISTIC_EMIT', 'recompiling produced a different skeleton.atlas');
|
|
3363
|
-
}
|
|
3364
|
-
// The model document is compared like the Spine pair (issue #922): it is
|
|
3365
|
-
// written beside them, and a map iterated in an order nothing fixed would
|
|
3366
|
-
// reach it before it reached either of them — the document writes every
|
|
3367
|
-
// `Map` of the model as an array in the map's order.
|
|
3368
|
-
if (input.modelText === undefined) {
|
|
3369
|
-
fail('A18_DETERMINISTIC_EMIT', 'a second compile was handed over with no first model document to compare its skeleton.model.json against');
|
|
3370
|
-
} else if (input.reEmit.modelText !== input.modelText) {
|
|
3371
|
-
fail('A18_DETERMINISTIC_EMIT', `recompiling produced a different skeleton.model.json (first differing line ${firstDifferingLine(input.modelText, input.reEmit.modelText)})`);
|
|
3372
|
-
}
|
|
3332
|
+
const again = input.reEmit;
|
|
3333
|
+
const encoding =
|
|
3334
|
+
again === undefined
|
|
3335
|
+
? []
|
|
3336
|
+
: [
|
|
3337
|
+
...(again.skeletonText !== input.skeletonText ? ['recompiling produced a different skeleton.json'] : []),
|
|
3338
|
+
...(again.atlasText !== input.atlasText ? ['recompiling produced a different skeleton.atlas'] : []),
|
|
3339
|
+
];
|
|
3340
|
+
a18DeterministicEmit(verdicts, { again: again !== undefined, encoding, first: input.modelText, second: again?.modelText ?? '' });
|
|
3373
3341
|
});
|
|
3374
3342
|
|
|
3375
3343
|
// --- every assertion leaves a row ----------------------------------------
|
|
@@ -3444,44 +3412,6 @@ function meshWeightsOf(mesh: MeshAttachment): Array<Array<{ bone: number; weight
|
|
|
3444
3412
|
return out;
|
|
3445
3413
|
}
|
|
3446
3414
|
|
|
3447
|
-
export function reportLines(report: ValidateReport): string[] {
|
|
3448
|
-
const lines: string[] = [];
|
|
3449
|
-
// The profile goes FIRST and names what it left out. A report that says
|
|
3450
|
-
// "green" without saying which rulebook produced it is the one thing this
|
|
3451
|
-
// switch could make worse than no switch: `--profile spine` green means
|
|
3452
|
-
// "valid Spine", never "passes the renderer policy".
|
|
3453
|
-
const renderer = report.profileSkipped.filter((p) => p.kind === 'renderer').length;
|
|
3454
|
-
const archetype = report.profileSkipped.filter((p) => p.kind === 'archetype').length;
|
|
3455
|
-
lines.push(
|
|
3456
|
-
report.profileSkipped.length === 0
|
|
3457
|
-
? ` .. profile ${report.profile} — every assertion applies`
|
|
3458
|
-
: ` .. profile ${report.profile} — ${renderer} renderer-policy and ${archetype} archetype assertion(s) do not apply`,
|
|
3459
|
-
);
|
|
3460
|
-
for (const name of report.passed) lines.push(` PASS ${name}`);
|
|
3461
|
-
for (const s of report.skipped) lines.push(` SKIP ${s.assertion}: ${s.reason}`);
|
|
3462
|
-
for (const p of report.profileSkipped) lines.push(` PROF ${p.assertion}: ${p.kind} rule, not in profile "${report.profile}"`);
|
|
3463
|
-
for (const f of report.failures) lines.push(` FAIL ${f.assertion}: ${f.detail}`);
|
|
3464
|
-
// How many of them MEASURED anything, which is the figure the rows above do
|
|
3465
|
-
// not hand a reader (issue #568). Counting `PASS` lines answers a different
|
|
3466
|
-
// question — before the sweep that closed #568 a run could print seven of
|
|
3467
|
-
// them over a candidate on which five rules had not executed at all.
|
|
3468
|
-
//
|
|
3469
|
-
// ⚠️ Every figure here is a count of ASSERTIONS and not of rows, which is why
|
|
3470
|
-
// the failed side is a Set: `fail()` is called once per finding, so one
|
|
3471
|
-
// assertion can print six `FAIL` lines, and a line-count would report 47 of
|
|
3472
|
-
// 42. The four buckets partition `ASSERTION_NAMES`, so the total is derived
|
|
3473
|
-
// by adding them rather than stated — a `42` written here would be the one
|
|
3474
|
-
// number in the report that no run could contradict.
|
|
3475
|
-
const failed = new Set(report.failures.map((f) => f.assertion));
|
|
3476
|
-
const measured = report.passed.length + failed.size;
|
|
3477
|
-
const total = measured + report.skipped.length + report.profileSkipped.length;
|
|
3478
|
-
lines.push(
|
|
3479
|
-
` .. ${total} assertions: ${measured} measured (${report.passed.length} passed, ${failed.size} failed), ` +
|
|
3480
|
-
`${report.skipped.length} skipped, ${report.profileSkipped.length} not in profile "${report.profile}"`,
|
|
3481
|
-
);
|
|
3482
|
-
return lines;
|
|
3483
|
-
}
|
|
3484
|
-
|
|
3485
3415
|
export function atlasDirOf(atlasPath: string): string {
|
|
3486
3416
|
return dirname(resolve(atlasPath));
|
|
3487
3417
|
}
|