spine-rigc 1.9.4 → 2.0.1

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.
@@ -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, type CompileOptions } from '../compile.ts';
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 CompileResult } from '../types.ts';
25
- import { assertionCountForProfile, CLI_DEFAULT_PROFILE, reportLines, validate, VALIDATE_PROFILES, type ValidateProfile } from '../validate.ts';
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 { basename, dirname, join, resolve } from 'node:path';
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
- * Read `--profile`, defaulting to `spine` — see `CLI_DEFAULT_PROFILE`.
43
- *
44
- * An unknown name is a usage error rather than a silent fallback, and that
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
- function readIntFlag(flags: Record<string, string>, name: string, fallback: number): number {
115
- const raw = flags[name];
116
- if (raw === undefined) return fallback;
117
- if (!/^\d+$/.test(raw)) throw new UsageError(`--${name} takes a non-negative integer, got ${JSON.stringify(raw)}`);
118
- return Number(raw);
119
- }
120
-
121
- /**
122
- * Read `--page-edges`, or its default — `pot`.
123
- *
124
- * An unknown value is a usage error for `readProfile`'s reason: a typo that fell
125
- * back to `pot` would hand the caller who asked for the smaller page the bigger
126
- * one, green, and say nothing.
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
- ` PHYS ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] ` +
264
- `mix=${ph.mix}${ph.drivesMesh ? ' <- drives a mesh: its canvas re-rasterises while the spring settles' : ''}`,
57
+ ` .. ${Object.entries(report.stats)
58
+ .map(([k, v]) => `${k}=${v}`)
59
+ .join(' ')}`,
265
60
  );
266
- }
267
-
268
- // The model's document is spelled before the gate, so the text A18 compares
269
- // is the text written, and a model the document cannot carry is refused
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
- writeFileSync(join(opts.outDir, 'skeleton.json'), result.skeletonText);
379
- writeFileSync(join(opts.outDir, 'skeleton.atlas'), atlasText);
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
- const { skeletonPath, atlasPath } = resolveArtifacts(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas);
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 } = resolveArtifacts(target, undefined);
513
- for (const path of [skeletonPath, atlasPath]) {
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 } = resolveArtifacts(target, undefined);
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/compile.ts CHANGED
@@ -338,6 +338,54 @@ export function editorAnimationOrder(names: readonly string[]): string[] {
338
338
  return keyedOrder(editorNamesInOrder(names, 'animations'));
339
339
  }
340
340
 
341
+ /** Where a slider's animation stands in the file and after the editor's re-sort, when the two differ (`sliderAnimationTheEditorRepoints`). */
342
+ interface RepointedSlider {
343
+ /** The animation's index in the order the file keys (`editorAnimationOrder`). */
344
+ fileIndex: number;
345
+ /** Its index in the comparator's order, which the editor re-sorts the object into. */
346
+ editorIndex: number;
347
+ /** The animation the editor puts at `fileIndex` — the one the slider comes back naming. */
348
+ becomes: string;
349
+ /** The array-index names the file lists ahead of the comparator's order. */
350
+ indexNames: string[];
351
+ }
352
+
353
+ /**
354
+ * Whether an editor import moves a slider off `animation` — `null` when it does
355
+ * not (issue #1040).
356
+ *
357
+ * 🔁 **Measured, not inferred.** The editor holds a slider's animation by its
358
+ * INDEX in the text's order and re-sorts the `animations` object into its
359
+ * comparator's order (#535). Since #1034 the file's order is the comparator's
360
+ * with every array-index name (`5`, `10`) first, because a JSON object lists
361
+ * those keys first however it was filled. On 2026-10-02 a build keying
362
+ * `5, 10, -a, 01, 2b` went through Spine 4.3.26 and came back
363
+ * `-a, 01, 2b, 5, 10`, with `yaw -> "5"` returned as `yaw -> "-a"` (index 0
364
+ * before and after) and `tilt -> "-a"` as `tilt -> "2b"` (index 2). So the
365
+ * slider moves exactly when its animation's index differs between the two
366
+ * orders, and only then: an array-index name elsewhere in the list repoints
367
+ * nothing, and is not refused.
368
+ *
369
+ * A name set the comparator cannot order is R10's refusal, raised when the
370
+ * animations are emitted; this check has no order to read there and says
371
+ * nothing rather than raising it a second time in other words.
372
+ */
373
+ function sliderAnimationTheEditorRepoints(animation: string, names: readonly string[]): RepointedSlider | null {
374
+ let sorted: string[];
375
+ try {
376
+ sorted = editorNamesInOrder(names, 'animations');
377
+ } catch (err) {
378
+ if (err instanceof CompileError) return null;
379
+ throw err;
380
+ }
381
+ const file = keyedOrder(sorted);
382
+ const fileIndex = file.indexOf(animation);
383
+ if (sorted[fileIndex] === animation) return null;
384
+ // An array-index name is one an object lists ahead of a key it was given first — the language's rule, not a restatement of it.
385
+ const indexNames = file.filter((name) => keyedOrder(['\u0000', name])[0] === name);
386
+ return { fileIndex, editorIndex: sorted.indexOf(animation), becomes: sorted[fileIndex], indexNames };
387
+ }
388
+
341
389
  /**
342
390
  * The order the editor writes a list of skin or animation names in, refusing by
343
391
  * name every pair the five round trips leave open.
@@ -6785,6 +6833,21 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): M
6785
6833
  (known.length ? ` (it declares: ${known.join(', ')})` : ' (it declares none at all)'),
6786
6834
  );
6787
6835
  }
6836
+ // 🔁 The editor keeps a slider's animation by index and re-sorts the object
6837
+ // the file lists array-index names first in (issue #1040, measured).
6838
+ const repointed = sliderAnimationTheEditorRepoints(animation, [...ctx.animationNames]);
6839
+ if (repointed !== null) {
6840
+ const spelled = repointed.indexNames.map((name) => `"${name}"`).join(', ');
6841
+ throw new CompileError(
6842
+ `${where}: applies animation "${animation}", which the Spine file lists at index ${repointed.fileIndex} of ` +
6843
+ `its animations and the Spine editor sorts to index ${repointed.editorIndex} — so an editor import ` +
6844
+ `repoints this slider to "${repointed.becomes}", in a file that still parses and gates green. A JSON object ` +
6845
+ `lists every array-index key (${spelled} here) before the rest, the editor re-sorts the animations into its ` +
6846
+ 'own order on import, and a slider holds its animation by index (measured on Spine 4.3.26; AUTHORING R10). ' +
6847
+ `Rename ${spelled} so no animation name is a plain non-negative integer — a prefix or a leading zero ` +
6848
+ `("a${repointed.indexNames[0]}", "0${repointed.indexNames[0]}") puts the file's order and the editor's in step.`,
6849
+ );
6850
+ }
6788
6851
  out.animation = animation;
6789
6852
  for (const field of ['additive', 'loop'] as const) {
6790
6853
  if (spec[field] !== undefined) out[field] = spec[field];