spine-rigc 0.20.3 → 0.22.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/src/compile.ts CHANGED
@@ -35,6 +35,10 @@ import {
35
35
  } from './depth.ts';
36
36
  import { CompileError, NotImplementedError } from './errors.ts';
37
37
  import { parseJsonWithPosition } from './json-position.ts';
38
+ // The "did you mean" list on a missing atlas region, from the one implementation
39
+ // of it — the same search serves `refuseUnknownKeys`, and a second copy here with
40
+ // a threshold edited is how such a pair drifts apart.
41
+ import { nearMisses } from './keys.ts';
38
42
  import { parseMotionSpec } from './motion.ts';
39
43
  import {
40
44
  parseRigSpec,
@@ -42,6 +46,7 @@ import {
42
46
  RIG_PATH_POSITION_MODES,
43
47
  RIG_PATH_ROTATE_MODES,
44
48
  RIG_PATH_SPACING_MODES,
49
+ RIG_SCALE_Y_MODES,
45
50
  RIG_SKIN_CONSTRAINT_KEYS,
46
51
  splitRigSkin,
47
52
  type RigAttachment,
@@ -53,6 +58,7 @@ import {
53
58
  type RigMeshBinding,
54
59
  type RigPathAttachment,
55
60
  type RigRegionAttachment,
61
+ type RigSkinParts,
56
62
  type RigSpec,
57
63
  type RigVertexGeometry,
58
64
  type RigDepthMap,
@@ -137,8 +143,23 @@ export const SPINE_VERSION = '4.3.13';
137
143
  const FRAME = 1 / 60;
138
144
 
139
145
  /**
140
- * The order the emitted `animations` object is keyed in: **codepoint-ascending
141
- * by name**, which is the order the Spine editor writes it back out in.
146
+ * The order the emitted `animations` object is keyed in: **the Spine editor's own
147
+ * comparator, as far as anybody has measured it** — natural and case-insensitive
148
+ * (#539) — with every name set refused on which one of that comparator's four
149
+ * UNMEASURED choices could decide a pair.
150
+ *
151
+ * ⚠️ This emitted **codepoint** order until issue #543, and the refusal was the
152
+ * thing that made codepoint agree with the editor: every pair the two could order
153
+ * differently was a `CompileError`. That is sound and it over-refuses by
154
+ * construction, because codepoint is not a member of the family it is being
155
+ * defended against — it is neither natural nor case-insensitive — so `Turn`
156
+ * against `sweep` and `turn10` against `turn2`, **the two pairs the editor was
157
+ * directly measured on**, were refused rather than emitted in the order the
158
+ * editor was measured returning. Sorting by a member of the measured family
159
+ * instead moves the refusal onto what is actually unmeasured, and moves no byte:
160
+ * on every set the codepoint rule accepted, the two orders are the same (that is
161
+ * what the rule guaranteed), which is why this change is invisible to every rig
162
+ * in the tree.
142
163
  *
143
164
  * ## Why the emitter has an opinion about this at all
144
165
  *
@@ -162,8 +183,7 @@ const FRAME = 1 / 60;
162
183
  * 10.4655 / 8.4961 / 8.7140 to 0.3035 / 0.0769 / 0.0588, and `yaw -> "turn"`
163
184
  * came back intact.
164
185
  *
165
- * ## Codepoint is NOT the editor's comparator, and this is why it is still what
166
- * is emitted
186
+ * ## What the editor's comparator is, and which parts of it are known
167
187
  *
168
188
  * ⚠️ This comment used to say that codepoint order "is what every editor-authored
169
189
  * file on hand is in", and that sentence was false when it was written. The
@@ -195,47 +215,122 @@ const FRAME = 1 / 60;
195
215
  * `turn10, turn2, zoom` came back `turn2, turn10, zoom`. The intersection leaves
196
216
  * one hypothesis — **natural order, case-insensitive**.
197
217
  *
198
- * ⇒ So why not sort that way? Because "natural, case-insensitive" is a family of
199
- * comparators rather than one. Leading zeros (`turn01` against `turn1`), a pure
200
- * case tie (`Turn` against `turn`), whether a digit run sorts before a word, and
201
- * what a separator is worth are each a free choice, and **the editor's answers to
202
- * them are not measured**. Writing a comparator means choosing all four, and a
218
+ * ⚠️ "Natural, case-insensitive" is a **family** of comparators rather than one.
219
+ * Leading zeros (`turn01` against `turn1`), a pure case tie (`Turn` against
220
+ * `turn`), whether a digit run sorts before a word, and what a separator is worth
221
+ * are each a free choice, and **the editor's answers to them are not measured**.
222
+ * Writing a comparator that sorts every name set means choosing all four, and a
203
223
  * chosen-but-unmeasured comparator is exactly how #537 landed.
204
224
  *
205
- * 🔒 It is also the one claim in the emitter that no gate can see. spine-core
206
- * reads back everything else rigc writes; it does not sort, so the emitted key
207
- * order has no oracle behind it. What rigc *can* check, with no editor and no
208
- * comparator, is much smaller and is the whole of what matters: whether the names
209
- * in front of it are a set on which every candidate comparator agrees. On such a
210
- * set codepoint **is** the editor's order, whatever the editor's comparator turns
211
- * out to be — and on every other set the build stops with both names in the
212
- * message. See `orderTurnsOnTheComparator`.
213
- *
214
- * Codepoint also stays locale-independent, which `A18` requires: `localeCompare`
215
- * would make the emitted bytes a property of the machine.
225
+ * ⇒ 🔑 **So the family is never asked to sort a pair one of those four decides.**
226
+ * `measuredOrder` returns a verdict only where every member of the family must
227
+ * agree, and `refuseNamesTheEditorCouldKeyDifferently` stops the build on any pair
228
+ * where it cannot — which means the four choices below are **unobservable in the
229
+ * output**, and the claim the emit makes is the one #542 established, widened:
230
+ * *on this name set, every comparator consistent with what has been measured
231
+ * produces this order.* Checkable inside rigc, with no editor and no oracle.
232
+ *
233
+ * 🔒 The emitted key order is still the one claim in the emitter no gate can see.
234
+ * spine-core reads back everything else rigc writes; it does not sort. What
235
+ * replaces an oracle is the quantifier: the order is not *a* comparator's answer,
236
+ * it is the answer they all give.
237
+ *
238
+ * ⭐ The two-sided result is on editor-written data. Sorting each of the 105
239
+ * collections above by `measuredOrder` reproduces **105 of 105** — including the
240
+ * three no codepoint sort can — and refuses **none** of them. The codepoint rule
241
+ * reproduced 102 and refused those same 3.
242
+ *
243
+ * The family is hand-rolled and locale-independent, which `A18` requires:
244
+ * `localeCompare` would make the emitted bytes a property of the machine.
216
245
  */
217
246
  function editorAnimationOrder<T>(animations: Record<string, T>): Record<string, T> {
218
- // One definition of "the order rigc emits", shared with the check below, so the
219
- // two cannot drift into checking different things.
220
- const names = Object.keys(animations).sort(codepoint);
221
- refuseNamesTheEditorCouldKeyDifferently(names);
247
+ const names = Object.keys(animations);
248
+ // ⭐ The sort is the check's own OUTPUT rather than a second reading of the same
249
+ // names: the refusal walks every pair and hands back the verdict it certified
250
+ // for each, so "the order rigc emits" and "the order rigc checked" cannot drift
251
+ // into two readings the way a shared comparator still can.
252
+ const verdicts = refuseNamesTheEditorCouldKeyDifferently(names, ANIMATION_ORDER);
253
+ // Nested rather than keyed on a joined string: any character this could join
254
+ // on is one an animation name is allowed to contain, and two pairs that
255
+ // collided would silently share one verdict.
256
+ names.sort((a, b) => verdicts.get(a)?.get(b) ?? 0);
222
257
  const ordered: Record<string, T> = {};
223
258
  for (const name of names) ordered[name] = animations[name];
224
259
  return ordered;
225
260
  }
226
261
 
262
+ /** The skin the editor keeps at index 0 whatever its name sorts as. */
263
+ const DEFAULT_SKIN = 'default';
264
+
265
+ /**
266
+ * The order the emitted `skins` array is written in: **`default` first, then the
267
+ * rest in the order the editor was measured returning them** — with every pair
268
+ * refused on which a comparator consistent with that measurement could disagree.
269
+ *
270
+ * ## Why the emitter has an opinion about this at all
271
+ *
272
+ * The same reason it has one about `animations` (#535), in the collection nobody
273
+ * had checked. `SkeletonBinary` addresses skins by ORDINAL — `skins[readInt()]`
274
+ * for an attachment timeline, `skins[linkedMesh.skinIndex]` for a linked mesh —
275
+ * so an editor that writes this array in another order repoints every such
276
+ * reference, silently, in a file that still parses.
277
+ *
278
+ * ⚠️ `src/types.ts` said for two releases that `skins` was one of the arrays an
279
+ * editor leaves alone, and #544 softened that to *unmeasured* rather than
280
+ * retracting it. Issue #541 measured it: a rig built `default, zulu, mike,
281
+ * alpha` exported `default, alpha, mike, zulu`. Three arrays — `bones` (30),
282
+ * `slots` (24), `constraints` (3) — had come back element for element, and the
283
+ * belief was a generalisation off those three.
284
+ *
285
+ * ## What is measured, and it is two separate facts
286
+ *
287
+ * - **`default` is pinned, not sorted.** `alpha` sorts before `default` under
288
+ * every candidate comparator and came back *after* it. That is the whole
289
+ * evidence, and it is decisive: the runtime's own `defaultSkin` is index 0.
290
+ * - **The rest came back `alpha, mike, zulu`.** ⚠️ Which separates nothing.
291
+ * Those three names are lower-case ASCII with no digits and no separators, so
292
+ * codepoint, case-folded, natural, and every collator agree on them. The
293
+ * editor's skin comparator is **not established**, and a rig with `Zulu`,
294
+ * `mike10`, `mike2` in it is what would establish it.
295
+ *
296
+ * 🔑 So this deliberately does NOT reuse `measuredOrder` alone. #539 measured
297
+ * the editor's comparator for `animations` and `events` — natural, and
298
+ * case-insensitive — and #543 narrowed the animation refusal onto what is left
299
+ * unmeasured *inside that family*. None of that is a measurement about skins:
300
+ * it is one program, and the inference that one program sorts two collections
301
+ * the same way is exactly the shape of the inference that put `skins` on the
302
+ * safe side of the list in the first place. `measuredSkinOrder` therefore
303
+ * certifies a pair only where the natural case-insensitive family **and plain
304
+ * codepoint** agree, which is the pre-#543 rule — correct here for the reason
305
+ * it was too strong there: for animations, codepoint had been *refuted*; for
306
+ * skins, nothing has refuted anything.
307
+ *
308
+ * ⇒ Every skin name in this tree is lower-case ASCII without digits, so no
309
+ * emitted byte moves and no rig is refused. What moves is the claim.
310
+ */
311
+ function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
312
+ const pinned = skins.filter((skin) => skin.name === DEFAULT_SKIN);
313
+ const rest = skins.filter((skin) => skin.name !== DEFAULT_SKIN);
314
+ const verdicts = refuseNamesTheEditorCouldKeyDifferently(
315
+ rest.map((skin) => skin.name),
316
+ SKIN_ORDER,
317
+ );
318
+ rest.sort((a, b) => verdicts.get(a.name)?.get(b.name) ?? 0);
319
+ return [...pinned, ...rest];
320
+ }
321
+
227
322
  /**
228
- * The characters whose relative order every candidate comparator agrees on: the
229
- * digits and, once case is folded, the lower-case ASCII letters. Everything else
230
- * — `-`, `_`, a space, an accented letter — is worth something different to a
231
- * collator than to a codepoint compare, so a pair those decide is not settled.
323
+ * The characters whose relative order every member of the measured family agrees
324
+ * on: the digits and, once case is folded, the lower-case ASCII letters.
325
+ * Everything else — `-`, `_`, a space, an accented letter — is worth something
326
+ * different to a collator that ignores punctuation than to one that does not, so
327
+ * a pair those decide is not settled.
232
328
  */
233
329
  const SETTLED_CHARS = /[0-9a-z]/;
234
330
 
235
331
  /** Maximal runs of digits and of non-digits, which is what "natural" compares. */
236
332
  const runsOf = (name: string): string[] => name.match(/\d+|\D+/g) ?? [];
237
333
 
238
- const sign = (n: number): number => (n < 0 ? -1 : n > 0 ? 1 : 0);
239
334
  const codepoint = (a: string, b: string): number => (a < b ? -1 : a > b ? 1 : 0);
240
335
 
241
336
  /** What made a pair's order a matter of opinion, and what the author has to do. */
@@ -246,58 +341,72 @@ interface Ambiguity {
246
341
  }
247
342
 
248
343
  /**
249
- * Whether these two names can be ordered two ways — `null` when every comparator
250
- * the editor could be using puts them in the order rigc emits them in.
344
+ * How the editor's comparator orders these two names — or, when one of its four
345
+ * unmeasured choices is what decides them, what that choice is and what the
346
+ * author has to do about it.
251
347
  *
252
- * ## The predicate, and why it is a certificate rather than a hazard list
348
+ * ## It is a certificate, not a hazard list
253
349
  *
254
350
  * Issue #539 proposed checking for "two names differing only in case, or sharing
255
351
  * a prefix followed by digit runs of unequal length". **That list misses the rig
256
352
  * that produced the measurement**: `Turn` and `sweep` differ in much more than
257
353
  * case and carry no digits, and they are the pair the editor reordered. A list of
258
354
  * hazards is open-ended — one was missing the day it was written — so what is
259
- * implemented is the other direction: a pair is refused unless the position that
260
- * decides it is one every candidate comparator must read the same way.
261
- *
262
- * There are exactly three ways to lose that, and each is a free choice in some
263
- * real comparator:
264
- *
265
- * - **case** — folding reverses them (`Turn` before `sweep` by codepoint, after it
266
- * folded), or they fold together and only a tie-break separates them.
267
- * - **number** — a digit run decides, and reading it as text disagrees with
268
- * reading it as a number (`turn10` before `turn2`), or two runs are the same
269
- * number written two ways (`turn01`, `turn1`), or a run meets a word and
270
- * comparators differ on which sorts first.
355
+ * implemented is the other direction: a verdict is returned only where the
356
+ * position that decides the pair is one **every** comparator consistent with the
357
+ * measurement must read the same way, and everything else stops the build.
358
+ *
359
+ * ## The four choices this returns an `Ambiguity` for, and why each is free
360
+ *
361
+ * - **case** (`Turn` against `turn`) — the two fold together, so only a tie-break
362
+ * separates them and nobody has measured which way it breaks.
363
+ * - **number**, one number written twice (`turn01` against `turn1`) — the runs are
364
+ * numerically equal, so again only a tie-break separates them: shorter-first,
365
+ * longer-first and lexicographic are all real implementations.
366
+ * - **number**, a digit run against a word (`1turn` against `turn`) — comparators
367
+ * differ on whether a number sorts before a word.
271
368
  * - **separator** — the deciding character is neither a letter nor a digit. This
272
369
  * one covers two adversaries at once: a collator that treats `-` or a space as
273
370
  * ignorable, and the plain fact that `_` sits *between* `Z` and `a`, so
274
371
  * `x.toUpperCase()` and `x.toLowerCase()` order `wave_x` against `wavea`
275
372
  * oppositely. Neither name needs a capital in it for that to bite.
276
373
  *
374
+ * ⚠️ There used to be a fifth and a sixth, and both were artefacts of emitting
375
+ * codepoint rather than facts about the editor (issue #543). A pair folding the
376
+ * other way (`Turn` against `sweep`) and two digit runs of unequal width
377
+ * (`turn10` against `turn2`) are exactly the two pairs the editor **was**
378
+ * measured on, and every comparator in the family orders them the same way — so
379
+ * they are now emitted in that order rather than refused. `flare1` against
380
+ * `flare10`, the over-refusal #542 named and accepted, goes with them.
381
+ *
277
382
  * ## What it was tested against
278
383
  *
279
384
  * 22 comparators — 12 hand-rolled naturals (fold up/down × three leading-zero
280
- * tie-breaks × digits-before-words/after) , 2 plain case-insensitive ones, and 8
385
+ * tie-breaks × digits-before/after words), 2 plain case-insensitive ones, and 8
281
386
  * `Intl.Collator`s (`numeric: true`, four sensitivities × `ignorePunctuation`) —
282
387
  * over every pair of names up to 4 characters from `{a B 0 1 2 - _ space}`:
283
- * **10,948,860 pairs, zero escapes**, where an escape is a pair some comparator
284
- * reorders that this predicate calls safe. Two earlier drafts did have escapes,
285
- * and both are why a clause above exists: `ignorePunctuation` found `arc-tracker`
286
- * against `arcs`, and a terminal digit run found `a0` against `a00`.
287
- *
288
- * ⭐ The two-sided result is on real data. Across the same 105 collections the
289
- * survey above defines, the ones this predicate refuses are **exactly** the 3
290
- * whose own key order a codepoint sort cannot reproduce — no false positive, no
291
- * false negative, against names the editor itself wrote.
292
- *
293
- * ⚠️ It does over-refuse, and the direction is deliberate: `flare1` against
294
- * `flare10` is safe under all 22 and refused anyway, because the rule is stated
295
- * on digit-run width rather than on a comparison, and the repair that fixes the
296
- * genuinely broken sibling (`flare9` against `flare10`) fixes both.
388
+ * **10,948,860 pairs**. Three figures come off that bank and each is load-bearing:
389
+ *
390
+ * - **0 escapes**, where an escape is a pair some comparator orders differently
391
+ * from the verdict returned here. Measured over the **20 natural** members —
392
+ * the 12 hand-rolled and the 8 collators, all of which read a digit run as a
393
+ * number. The 2 plain case-insensitive comparators do dissent (291,684 pairs),
394
+ * and they are the two #539's own measurement refutes: a comparator that reads
395
+ * `turn10` as text cannot return `turn2, turn10`.
396
+ * - **0 pairs move.** On every pair the codepoint rule accepted, this returns the
397
+ * codepoint order — which is why no emitted byte in the tree changes.
398
+ * - the refusal covers **9,140,115** pairs where the codepoint rule covered
399
+ * 10,218,136: 1,078,021 pairs are now built instead of renamed.
400
+ *
401
+ * ⭐ And the two-sided result is on real data: across the 105 editor-written
402
+ * collections the survey above defines, sorting by this reproduces all 105 and
403
+ * refuses none. No false positive, no false negative, against names the editor
404
+ * itself wrote.
297
405
  */
298
- function orderTurnsOnTheComparator(a: string, b: string): Ambiguity | null {
299
- const caseRepair = 'rename one of them so nothing but case has to be compared';
300
- const numberRepair = 'pad the digit runs to the same width, or rename so no number decides the order';
406
+ function measuredOrder(a: string, b: string): number | Ambiguity {
407
+ const caseRepair = 'rename one of them so they differ by more than letter case';
408
+ const zerosRepair = 'write the number one way — rename so the digit run has a single spelling, with leading zeros or without';
409
+ const wordRepair = 'rename so a run of digits never has to be compared against a word';
301
410
  const separatorRepair = 'rename so the first character that differs is a letter or a digit';
302
411
  const la = a.toLowerCase();
303
412
  const lb = b.toLowerCase();
@@ -332,13 +441,6 @@ function orderTurnsOnTheComparator(a: string, b: string): Ambiguity | null {
332
441
  repair: separatorRepair,
333
442
  };
334
443
  }
335
- if (sign(codepoint(a, b)) !== sign(codepoint(la, lb))) {
336
- return {
337
- kind: 'case',
338
- because: `codepoint puts "${a}" first only because of letter case; folded, "${b}" comes first`,
339
- repair: caseRepair,
340
- };
341
- }
342
444
  const ra = runsOf(la);
343
445
  const rb = runsOf(lb);
344
446
  for (let k = 0; k < Math.min(ra.length, rb.length); k++) {
@@ -348,21 +450,16 @@ function orderTurnsOnTheComparator(a: string, b: string): Ambiguity | null {
348
450
  const xIsDigits = /^\d/.test(x);
349
451
  const yIsDigits = /^\d/.test(y);
350
452
  if (xIsDigits && yIsDigits) {
351
- if (BigInt(x) === BigInt(y)) {
453
+ const nx = BigInt(x);
454
+ const ny = BigInt(y);
455
+ if (nx === ny) {
352
456
  return {
353
457
  kind: 'number',
354
458
  because: `"${x}" and "${y}" are the same number written two ways, so only a tie-break separates them`,
355
- repair: numberRepair,
459
+ repair: zerosRepair,
356
460
  };
357
461
  }
358
- if (x.length === y.length) return null;
359
- return {
360
- kind: 'number',
361
- because:
362
- `"${x}" and "${y}" are runs of digits of different widths, so reading them as text and reading them as ` +
363
- `the numbers ${BigInt(x)} and ${BigInt(y)} can disagree`,
364
- repair: numberRepair,
365
- };
462
+ return nx < ny ? -1 : 1;
366
463
  }
367
464
  if (xIsDigits !== yIsDigits) {
368
465
  return {
@@ -370,44 +467,167 @@ function orderTurnsOnTheComparator(a: string, b: string): Ambiguity | null {
370
467
  because:
371
468
  `one has the digits "${xIsDigits ? x : y}" where the other has "${xIsDigits ? y : x}", and comparators ` +
372
469
  'differ on whether a number sorts before a word',
373
- repair: numberRepair,
470
+ repair: wordRepair,
374
471
  };
375
472
  }
376
- return null;
473
+ return codepoint(x, y);
377
474
  }
378
- return null;
475
+ // One name's runs are a prefix of the other's — `wave` against `wave1`. Every
476
+ // comparator puts the shorter first; `la === lb` above already took the case
477
+ // where neither is longer.
478
+ return ra.length < rb.length ? -1 : 1;
479
+ }
480
+
481
+ /**
482
+ * How the editor orders two SKIN names — or what makes the pair a matter of
483
+ * opinion, under a family wider than `measuredOrder`'s by exactly one member.
484
+ *
485
+ * ## The extra member is plain codepoint, and it is here because nothing ruled
486
+ * it out
487
+ *
488
+ * #539 put two name sets through the editor and read them back, and what those
489
+ * two sets refute is that the editor sorts ANIMATIONS by codepoint: `Turn`
490
+ * before `sweep` and `turn10` before `turn2` are the codepoint answers, and the
491
+ * editor gave the other one both times. #543 is built on that refutation — it
492
+ * sorts by the natural, case-insensitive family and refuses only that family's
493
+ * four unmeasured choices.
494
+ *
495
+ * ⚠️ **The skins measurement refutes nothing.** `alpha, mike, zulu` is the
496
+ * answer every candidate gives, so codepoint is still standing for this
497
+ * collection. Carrying #543's narrowing over would be assuming that one editor
498
+ * sorts two collections by one comparator — plausible, unmeasured, and the same
499
+ * move that made `skins` "an array the editor leaves alone" for two releases.
500
+ *
501
+ * ⇒ A verdict is returned only where `measuredOrder` certifies the pair **and**
502
+ * codepoint agrees with it. On the pairs where they differ, the disagreement is
503
+ * itself the explanation: either folding the two reverses them, or reading a
504
+ * digit run as a number does, and the editor's choice between those readings is
505
+ * measured for animation names and not for skin names.
506
+ *
507
+ * 🔒 It is deliberately a *narrowing of `measuredOrder`* rather than a second
508
+ * comparator: one certificate, one place a family member is decided, and no way
509
+ * for the two to come to disagree about what "settled" means.
510
+ */
511
+ function measuredSkinOrder(a: string, b: string): number | Ambiguity {
512
+ const verdict = measuredOrder(a, b);
513
+ if (typeof verdict !== 'number' || codepoint(a, b) === verdict) return verdict;
514
+ // `measuredOrder` and codepoint can only part company where folding or a digit
515
+ // run decides the pair — everything else it already refuses. Which of the two
516
+ // it is, is what the author has to read.
517
+ const foldingDecides = codepoint(a.toLowerCase(), b.toLowerCase()) !== codepoint(a, b);
518
+ return foldingDecides
519
+ ? {
520
+ kind: 'case',
521
+ because:
522
+ `folded to one case "${a}" and "${b}" order the other way round, so whether the editor folds SKIN ` +
523
+ 'names decides this pair — and only its ANIMATION and EVENT names have been measured folded (#539)',
524
+ repair: 'rename one of them so their order does not turn on letter case',
525
+ }
526
+ : {
527
+ kind: 'number',
528
+ because:
529
+ `read as numbers the digit runs in "${a}" and "${b}" order the other way round from the same runs read ` +
530
+ 'as text, so whether the editor sorts SKIN names naturally decides this pair — and only its ANIMATION ' +
531
+ 'and EVENT names have been measured sorted naturally (#539)',
532
+ repair: 'pad the digit runs to the same width, or rename so no number decides the order',
533
+ };
379
534
  }
380
535
 
381
536
  /** How many pairs a refusal spells out before it starts counting them instead. */
382
537
  const PAIRS_SPELLED_OUT = 8;
383
538
 
384
539
  /**
385
- * Refuse a set of animation names the editor could key in an order rigc did not
386
- * emit — the check that lets `editorAnimationOrder` sort by codepoint without
387
- * claiming codepoint is the editor's rule.
388
- *
389
- * It is deliberately **not** conditional on the rig declaring a slider. A slider
390
- * is what makes the difference bite today, but the emitted order is a claim about
391
- * the editor either way, and a check that only ran when a slider was present would
392
- * make the claim hold for some rigs and not others — with nothing saying which.
393
- * Adding the slider is then the edit that refuses a rig that built yesterday.
540
+ * What one collection's order refusal has to say that the others' do not: what
541
+ * it is counting pairs of, which comparator family it certified them against,
542
+ * and what an order rigc got wrong would cost.
543
+ *
544
+ * Two collections share the walk below because they share the defect — a
545
+ * name-keyed collection whose ORDINAL is a reference in the format's binary half
546
+ * — and they must not share the sentence, because the family and the stakes are
547
+ * different and a reader acts on both.
394
548
  */
395
- function refuseNamesTheEditorCouldKeyDifferently(sorted: readonly string[]): void {
549
+ interface OrderedCollection {
550
+ /** Plural, for "N pair(s) of …": `animation names`, `skin names`. */
551
+ noun: string;
552
+ /** The certificate. A number is an order; an `Ambiguity` stops the build. */
553
+ order: (a: string, b: string) => number | Ambiguity;
554
+ /** Why rigc has an opinion, and what the family leaves open. Ends on a space. */
555
+ why: string;
556
+ }
557
+
558
+ const ANIMATION_ORDER: OrderedCollection = {
559
+ noun: 'animation names',
560
+ order: measuredOrder,
561
+ why:
562
+ 'rigc keys the emitted "animations" object in ' +
563
+ "the Spine editor's own comparator, which is natural and case-insensitive (#539) — but four of that " +
564
+ "comparator's choices have never been measured (a pure case tie, one number written two ways, a run of " +
565
+ 'digits against a word, and what a separator is worth), and each pair below is decided by one of them. A ' +
566
+ "slider's animation is an ORDINAL in the format's binary half, and an editor that keys these differently " +
567
+ 'repoints every slider whose animation moves index — silently, in a file that still parses (#535). ',
568
+ };
569
+
570
+ const SKIN_ORDER: OrderedCollection = {
571
+ noun: 'skin names',
572
+ order: measuredSkinOrder,
573
+ why:
574
+ 'rigc writes the emitted "skins" array with "default" first and the rest in the order the editor was ' +
575
+ 'measured returning them (#541) — but that measurement was taken on `alpha, mike, zulu`, which every ' +
576
+ "candidate comparator orders the same way, so the editor's skin comparator is not established and each " +
577
+ 'pair below is one the candidates disagree about. A skin is an ORDINAL in the format\'s binary half — ' +
578
+ '`skins[readInt()]` for an attachment timeline, `skins[skinIndex]` for a linked mesh — so an editor that ' +
579
+ 'writes them in another order repoints every such reference, silently, in a file that still parses. ',
580
+ };
581
+
582
+ /**
583
+ * Refuse a set of names the editor could write in an order rigc did not emit,
584
+ * and hand back the verdict for every pair that survived — the check that lets
585
+ * `editorAnimationOrder` and `editorSkinOrder` sort by a measured family without
586
+ * choosing a member of it.
587
+ *
588
+ * It is deliberately **not** conditional on anything: not on the rig declaring a
589
+ * slider, and not on the rig declaring the editor as a consumer. A slider is what
590
+ * makes the difference bite today and `invariants.editorRoundTrip` is how a rig
591
+ * says the editor is downstream, but the emitted order is a claim about the
592
+ * editor either way, and a check that only ran for some rigs would make the claim
593
+ * hold for some and not others with nothing in the file saying which — adding the
594
+ * slider, or the declaration, would then be the edit that refuses a rig that built
595
+ * yesterday. What issue #543 changed is the size of what is claimed, not who it is
596
+ * claimed for.
597
+ *
598
+ * ⚠️ `what` is a parameter and not a second copy of this walk because issue #541
599
+ * found the same defect in a second collection, and a copied loop is how the two
600
+ * come to check different things. What may NOT be shared is the family: `skins`
601
+ * and `animations` have been measured to different depths, and
602
+ * `OrderedCollection.order` is where each says which.
603
+ */
604
+ function refuseNamesTheEditorCouldKeyDifferently(
605
+ names: readonly string[],
606
+ what: OrderedCollection,
607
+ ): Map<string, Map<string, number>> {
608
+ const verdicts = new Map<string, Map<string, number>>();
609
+ const put = (x: string, y: string, v: number): void => {
610
+ const row = verdicts.get(x) ?? new Map<string, number>();
611
+ row.set(y, v);
612
+ verdicts.set(x, row);
613
+ };
396
614
  const found: string[] = [];
397
- for (let i = 0; i < sorted.length; i++) {
398
- for (let j = i + 1; j < sorted.length; j++) {
399
- const pair = orderTurnsOnTheComparator(sorted[i], sorted[j]);
400
- if (pair) found.push(`"${sorted[i]}" / "${sorted[j]}" (${pair.kind}) — ${pair.because}; ${pair.repair}`);
615
+ for (let i = 0; i < names.length; i++) {
616
+ put(names[i], names[i], 0);
617
+ for (let j = i + 1; j < names.length; j++) {
618
+ const verdict = what.order(names[i], names[j]);
619
+ if (typeof verdict === 'number') {
620
+ put(names[i], names[j], verdict);
621
+ put(names[j], names[i], -verdict);
622
+ } else {
623
+ found.push(`"${names[i]}" / "${names[j]}" (${verdict.kind}) — ${verdict.because}; ${verdict.repair}`);
624
+ }
401
625
  }
402
626
  }
403
- if (!found.length) return;
627
+ if (!found.length) return verdicts;
404
628
  const spelled = found.slice(0, PAIRS_SPELLED_OUT);
405
629
  throw new CompileError(
406
- `${found.length} pair(s) of animation names have no one order: rigc keys the emitted "animations" object ` +
407
- 'codepoint-ascending, which is the Spine editor\'s own order only for names whose order does not turn on ' +
408
- 'case, on a number, or on a separator. The editor sorts natural and case-insensitive (#539), a slider\'s ' +
409
- 'animation is an ORDINAL in the format\'s binary half, and an editor that keys these differently repoints ' +
410
- 'every slider whose animation moves index — silently, in a file that still parses (#535). ' +
630
+ `${found.length} pair(s) of ${what.noun} have no one order: ${what.why}` +
411
631
  `${spelled.join('. ')}` +
412
632
  (found.length > spelled.length ? `. …and ${found.length - spelled.length} more pair(s)` : ''),
413
633
  );
@@ -1028,44 +1248,6 @@ function readAtlasIn(path: string): AtlasSource {
1028
1248
  return { path, dir: dirname(path), parsed, byName };
1029
1249
  }
1030
1250
 
1031
- /**
1032
- * How far apart two names are, for the "did you mean" list on a missing region.
1033
- *
1034
- * Plain Levenshtein. An atlas has tens of regions and this runs once per
1035
- * refusal, so the O(n*m) table is free and a cheaper heuristic (shared prefix,
1036
- * substring) would miss the commonest real case — a transposition or one wrong
1037
- * character in a hand-typed `image`.
1038
- */
1039
- function nameDistance(a: string, b: string): number {
1040
- const rows = a.length + 1;
1041
- const cols = b.length + 1;
1042
- let previous = new Array<number>(cols);
1043
- for (let j = 0; j < cols; j++) previous[j] = j;
1044
- for (let i = 1; i < rows; i++) {
1045
- const current = new Array<number>(cols);
1046
- current[0] = i;
1047
- for (let j = 1; j < cols; j++) {
1048
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
1049
- current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
1050
- }
1051
- previous = current;
1052
- }
1053
- return previous[cols - 1];
1054
- }
1055
-
1056
- /** Up to five region names closest to the one that was not found. */
1057
- function nearMisses(wanted: string, known: Iterable<string>): string[] {
1058
- const scored: Array<{ name: string; d: number }> = [];
1059
- for (const name of known) {
1060
- const d = nameDistance(wanted.toLowerCase(), name.toLowerCase());
1061
- // Half the name's length, floored at 2: "leg" must not suggest "arm", and a
1062
- // long name may still be recognisable through several typos.
1063
- if (d <= Math.max(2, Math.floor(wanted.length / 2))) scored.push({ name, d });
1064
- }
1065
- scored.sort((x, y) => x.d - y.d || (x.name < y.name ? -1 : 1));
1066
- return scored.slice(0, 5).map((s) => s.name);
1067
- }
1068
-
1069
1251
  /**
1070
1252
  * One part resolved out of a region of a pre-packed atlas — CLI `--atlas-in`.
1071
1253
  *
@@ -1267,6 +1449,16 @@ export function compile(opts: CompileOptions): CompileResult {
1267
1449
  const images: CompiledImage[] = [];
1268
1450
  const droppedStates: CompileResult['droppedStates'] = [];
1269
1451
  const seenRegions = new Set<string>();
1452
+ /**
1453
+ * Region name -> the absolute path of the file whose pixels that region holds.
1454
+ *
1455
+ * ⭐ A region is named by its PNG's **basename**, so two files in two
1456
+ * directories can want one name, and the loser draws the winner's art with
1457
+ * nothing said (issue #555). `seenRegions` answers "is this name taken"; this
1458
+ * answers "by what", which is the half a refusal has to name — and it is the
1459
+ * same record that lets one PNG legitimately serve two skins.
1460
+ */
1461
+ const regionSource = new Map<string, string>();
1270
1462
  /**
1271
1463
  * Every directory the spec names a part PNG in — `skeletonImagesPath` reads it.
1272
1464
  * Recorded from the spec's own path whether or not the pixels came from there:
@@ -1289,11 +1481,53 @@ export function compile(opts: CompileOptions): CompileResult {
1289
1481
  ? fromLoosePng(relPath, loosePath, region, isBase, outDir)
1290
1482
  : resolveFromAtlas(relPath, region, isBase, outDir, atlasIn);
1291
1483
  seenRegions.add(region);
1484
+ regionSource.set(region, loosePath);
1292
1485
  partDirs.add(dirname(loosePath));
1293
1486
  images.push(img);
1294
1487
  return img;
1295
1488
  };
1296
1489
 
1490
+ /**
1491
+ * Measure and atlas the art ONE skin attachment names — once per file, not
1492
+ * once per placeholder (issue #555).
1493
+ *
1494
+ * 🚨 The defect this replaces. The gather loop below deduplicated the slot's
1495
+ * attachment NAMES, and the call to `addImage` stood behind that `continue`,
1496
+ * so a placeholder two skins fill reached the atlas with the FIRST skin's art
1497
+ * and every later skin's PNG was never opened. Measured on a two-skin rig
1498
+ * before the repair: without stated sizes the compile refused with *a region
1499
+ * needs width and height — give them, or give an "image" and rigc will measure
1500
+ * the PNG*, which is the message telling the author to give the image they
1501
+ * gave; with sizes stated it built and `A00_ROUNDTRIP_PARSE` refused the
1502
+ * skeleton with *Region not found in atlas: patch_b*. Neither names the cause,
1503
+ * and both are the doctrine's own second bullet inverted — a miss refused
1504
+ * under somebody else's name.
1505
+ *
1506
+ * ⚠️ Two skins may legitimately share one PNG, which is why this is not simply
1507
+ * `addImage` unguarded: `addImage` refuses any repeat of a region name, and a
1508
+ * placeholder both skins point at the SAME file is one region on purpose. The
1509
+ * distinction is the FILE, so that is what is compared — and two different
1510
+ * files whose basenames collide are refused here by name rather than aliased,
1511
+ * because the loser silently draws the winner's pixels at the winner's size
1512
+ * and the whole gate stays green (measured: 15 assertions passed on exactly
1513
+ * that rig).
1514
+ */
1515
+ const addSkinImage = (relPath: string, where: string): void => {
1516
+ const region = basename(relPath, '.png');
1517
+ const already = regionSource.get(region);
1518
+ if (already === undefined) {
1519
+ addImage(relPath, imagesDir, false);
1520
+ return;
1521
+ }
1522
+ const wanted = resolve(imagesDir, relPath);
1523
+ if (already === wanted) return; // one file, two attachments: one region, deliberately
1524
+ throw new CompileError(
1525
+ `${where}: "${relPath}" and the art already atlased as region "${region}" are two different files — ` +
1526
+ `${wanted} and ${already}. An atlas region is named by its PNG's basename, so only one of the two can ` +
1527
+ 'hold that name and this attachment would draw the other file\'s pixels. Rename one of the PNGs.',
1528
+ );
1529
+ };
1530
+
1297
1531
  // A manifest may name a part the cut does not carry. A formation can declare
1298
1532
  // more slots than any one cut fills, and a cut that shares a sprite with the
1299
1533
  // scene around it has no plate of its own to point at — the manifest then
@@ -1471,16 +1705,25 @@ export function compile(opts: CompileOptions): CompileResult {
1471
1705
  }
1472
1706
  const names = rigAttachmentNames.get(slotName) ?? [];
1473
1707
  for (const [placeholder, att] of Object.entries(placeholders)) {
1474
- if (names.includes(placeholder)) continue;
1475
- names.push(placeholder);
1708
+ // Two concerns, two conditions. The list is the slot's PLACEHOLDER list
1709
+ // and a placeholder several skins fill belongs in it once; the art is
1710
+ // per ATTACHMENT, and there are as many of those as there are skins
1711
+ // filling it. Standing behind one `continue`, the second was the first's
1712
+ // arithmetic (issue #555).
1713
+ if (!names.includes(placeholder)) names.push(placeholder);
1476
1714
  const image = (att as RigRegionAttachment).image;
1477
- if (typeof image === 'string' && !seenRegions.has(basename(image, '.png'))) {
1478
- addImage(image, imagesDir, false);
1715
+ if (typeof image === 'string') {
1716
+ addSkinImage(image, `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`);
1479
1717
  }
1480
1718
  }
1481
1719
  rigAttachmentNames.set(slotName, names);
1482
1720
  }
1483
1721
  }
1722
+ // Which (slot, placeholder) pairs more than one skin fills — the pairs whose
1723
+ // entries have to carry an attachment `name` of their own. Computed here, off
1724
+ // the normalised skin table, so the slot loop below reads a decision rather
1725
+ // than re-deriving one per attachment.
1726
+ const contested = contestedPlaceholders(skinNames, skinParts);
1484
1727
 
1485
1728
  // -- 2. atlas --------------------------------------------------------------
1486
1729
  //
@@ -1638,9 +1881,10 @@ export function compile(opts: CompileOptions): CompileResult {
1638
1881
  const placeholders = skinParts.get(skinName)!.attachments[rigSlot.name];
1639
1882
  if (!placeholders) continue;
1640
1883
  const perSlot: Record<string, SpineAttachment> = {};
1884
+ const shared = contested.get(rigSlot.name);
1641
1885
  for (const [placeholder, att] of Object.entries(placeholders)) {
1642
1886
  const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
1643
- perSlot[placeholder] = buildRigAttachment(att, placeholder, where, {
1887
+ const built = buildRigAttachment(att, placeholder, where, {
1644
1888
  images,
1645
1889
  bones,
1646
1890
  transforms,
@@ -1653,6 +1897,12 @@ export function compile(opts: CompileOptions): CompileResult {
1653
1897
  depths: attachmentDepths,
1654
1898
  slotNames: new Set(rig.slots.map((s) => s.name)),
1655
1899
  });
1900
+ // The name is put on AFTER the builder rather than inside it: five
1901
+ // builders write five shapes, the rule is one rule, and a rule that has
1902
+ // to be remembered in five places is a rule that will be kept in four.
1903
+ perSlot[placeholder] = shared?.has(placeholder)
1904
+ ? nameSkinAttachment(built, skinAttachmentName(skinName, placeholder), placeholder)
1905
+ : built;
1656
1906
  }
1657
1907
  tableFor(skinName)[rigSlot.name] = perSlot;
1658
1908
  }
@@ -2069,20 +2319,28 @@ export function compile(opts: CompileOptions): CompileResult {
2069
2319
  // `readSkeletonData`'s own order (`:372-443`). Every member list is a
2070
2320
  // conditional spread, so a rig that declares none emits the two-key entry it
2071
2321
  // always did, byte for byte.
2072
- skins: [...skinTables.entries()].map(([name, attachments]) => {
2073
- const parts = skinParts.get(name);
2074
- return {
2075
- name,
2076
- ...(parts?.bones.length ? { bones: parts.bones } : {}),
2077
- ...Object.fromEntries(
2078
- RIG_SKIN_CONSTRAINT_KEYS.filter((key) => parts?.constraints[key].length).map((key) => [
2079
- key,
2080
- parts!.constraints[key],
2081
- ]),
2082
- ),
2083
- attachments,
2084
- };
2085
- }),
2322
+ //
2323
+ // Ordered the way `animations` is and for the same reason — the editor
2324
+ // rewrites this array and the binary half addresses it by ordinal — with the
2325
+ // sort applied HERE rather than to `skinTables`, so everything upstream (the
2326
+ // path-slot table, every refusal that lists skins) still reads the order the
2327
+ // rig spec declared. See `editorSkinOrder`.
2328
+ skins: editorSkinOrder(
2329
+ [...skinTables.entries()].map(([name, attachments]) => {
2330
+ const parts = skinParts.get(name);
2331
+ return {
2332
+ name,
2333
+ ...(parts?.bones.length ? { bones: parts.bones } : {}),
2334
+ ...Object.fromEntries(
2335
+ RIG_SKIN_CONSTRAINT_KEYS.filter((key) => parts?.constraints[key].length).map((key) => [
2336
+ key,
2337
+ parts!.constraints[key],
2338
+ ]),
2339
+ ),
2340
+ attachments,
2341
+ };
2342
+ }),
2343
+ ),
2086
2344
  // Between `skins` and `animations`, which is where the editor writes it. A
2087
2345
  // conditional spread rather than an assignment after the literal, so the key
2088
2346
  // lands in that position instead of at the end.
@@ -2244,6 +2502,154 @@ function rotationOf(spec: RigBone, ctx: BoneContext): number | null {
2244
2502
  return spec.rotation ?? null;
2245
2503
  }
2246
2504
 
2505
+ // ---------------------------------------------------------------------------
2506
+ // attachment names, where one placeholder holds several attachments
2507
+ // ---------------------------------------------------------------------------
2508
+
2509
+ /**
2510
+ * What separates a skin's name from a placeholder's inside an attachment name.
2511
+ *
2512
+ * ⚠️ A separator is the one part of this that could collide with a name somebody
2513
+ * wrote, so it is measured rather than picked: across the **160** distinct
2514
+ * placeholder names and **159** distinct atlas region names in `examples/` and
2515
+ * `gallery/` — every editor-authored name this repository has — `/` occurs in
2516
+ * **0**, while `-` occurs in 85 / 86 and `_` in 37 / 37. It is also the character
2517
+ * the format already has a structure for, since an attachment's name doubles as
2518
+ * its texture path and a path is what `/` separates.
2519
+ *
2520
+ * 🔒 And the choice is not load-bearing anyway, which is the point of stating it
2521
+ * this way: `contestedPlaceholders` refuses the build if the name it composes is
2522
+ * one some other placeholder in the same slot already answers to. A separator
2523
+ * nobody uses makes that refusal rare; the refusal is what makes it safe.
2524
+ */
2525
+ const SKIN_ATTACHMENT_SEPARATOR = '/';
2526
+
2527
+ function skinAttachmentName(skinName: string, placeholder: string): string {
2528
+ return `${skinName}${SKIN_ATTACHMENT_SEPARATOR}${placeholder}`;
2529
+ }
2530
+
2531
+ /**
2532
+ * Which `(slot, placeholder)` pairs more than one skin fills — and, on the way,
2533
+ * the refusal that keeps the composed names from colliding with authored ones.
2534
+ *
2535
+ * ## The defect this exists for
2536
+ *
2537
+ * Two skins putting different art under one placeholder is what a skin IS, and
2538
+ * until issue #541 rigc emitted both entries with no `name`, which makes the
2539
+ * placeholder the name of both (`SkeletonJson.ts:526`). spine-core is happy —
2540
+ * its skin table is keyed by placeholder, so the two never meet. The Spine
2541
+ * editor refuses the whole import, and says exactly why:
2542
+ *
2543
+ * ERROR: Unable to import skeleton.
2544
+ * [error] Error reading skeleton: skins
2545
+ * Cause: [error] Error reading attachment: patch (MOw)
2546
+ * Cause: [error] Multiple attachments have the same name: patch patch
2547
+ *
2548
+ * Bisected on the emitted file: four skins REFUSED, deform timelines removed
2549
+ * REFUSED, `default` + one skin REFUSED, `default` alone IMPORTS, the second skin
2550
+ * given a distinct placeholder IMPORTS, and the same placeholder with **each
2551
+ * entry given its own `name`** IMPORTS — all four skins. So it is neither the
2552
+ * skin count nor the timelines; it is one name over several attachments.
2553
+ *
2554
+ * ## Only the contested pairs are named, and that is the whole rule
2555
+ *
2556
+ * A placeholder one skin fills keeps the emitted shape it has always had: no
2557
+ * `name`, no `path` it did not already carry. Every rig in this tree declares
2558
+ * exactly one skin, so **no emitted byte in the tree moves** — and a
2559
+ * multi-skin rig whose skins use distinct placeholders does not move either,
2560
+ * because nothing there is ambiguous to begin with.
2561
+ *
2562
+ * ⚠️ The scope of the editor's uniqueness rule is **not** skeleton-wide, and the
2563
+ * corpus proves it rather than a hypothesis doing so: `spineboy-pro.json`, which
2564
+ * the editor wrote, gives the name `head` to a region in slot `head` and to a
2565
+ * bounding box in slot `head-bb`, and names one `hoverglow-small` across eight
2566
+ * slots. What #541 refused was one slot. Composing from the skin makes the names
2567
+ * unique within the slot, which satisfies that scope and every narrower one;
2568
+ * nothing here claims to know which of them the editor actually applies, and an
2569
+ * assertion that policed the emitted artifact would have to.
2570
+ *
2571
+ * ## Why a composed name is not the compiler inventing a value
2572
+ *
2573
+ * rigc has always decided this attachment's name — it decided it was the
2574
+ * placeholder, silently, and that decision is the defect. What changes is the
2575
+ * derivation, not who makes it, and the new one is a function of two names the
2576
+ * spec wrote. Nothing is read off the art, and `path` — the field that says
2577
+ * which texture to draw — stays exactly what the spec stated or what the
2578
+ * attachment already resolved to.
2579
+ */
2580
+ function contestedPlaceholders(
2581
+ skinNames: readonly string[],
2582
+ skinParts: Map<string, RigSkinParts>,
2583
+ ): Map<string, Set<string>> {
2584
+ /** slot -> placeholder -> the skins that fill it, in declaration order. */
2585
+ const fillers = new Map<string, Map<string, string[]>>();
2586
+ for (const skinName of skinNames) {
2587
+ for (const [slotName, placeholders] of Object.entries(skinParts.get(skinName)!.attachments)) {
2588
+ const perSlot = fillers.get(slotName) ?? new Map<string, string[]>();
2589
+ for (const placeholder of Object.keys(placeholders)) {
2590
+ perSlot.set(placeholder, [...(perSlot.get(placeholder) ?? []), skinName]);
2591
+ }
2592
+ fillers.set(slotName, perSlot);
2593
+ }
2594
+ }
2595
+ const contested = new Map<string, Set<string>>();
2596
+ const collisions: string[] = [];
2597
+ for (const [slotName, perSlot] of fillers) {
2598
+ const shared = new Set([...perSlot].filter(([, skins]) => skins.length > 1).map(([placeholder]) => placeholder));
2599
+ if (shared.size) contested.set(slotName, shared);
2600
+ /** Emitted attachment name -> the first entry that claimed it. */
2601
+ const claimed = new Map<string, string>();
2602
+ for (const [placeholder, skins] of perSlot) {
2603
+ for (const skinName of skins) {
2604
+ const name = shared.has(placeholder) ? skinAttachmentName(skinName, placeholder) : placeholder;
2605
+ const site = `skin "${skinName}" placeholder "${placeholder}"`;
2606
+ const taken = claimed.get(name);
2607
+ if (taken === undefined) claimed.set(name, site);
2608
+ else collisions.push(`slot "${slotName}": ${taken} and ${site} would both be named "${name}"`);
2609
+ }
2610
+ }
2611
+ }
2612
+ if (collisions.length) {
2613
+ throw new CompileError(
2614
+ `${collisions.length} attachment name collision(s): a placeholder that more than one skin fills is emitted ` +
2615
+ `with the name "<skin>${SKIN_ATTACHMENT_SEPARATOR}<placeholder>", because the Spine editor refuses an import ` +
2616
+ 'in which one slot holds two attachments of one name (#541) — and here that composed name is one another ' +
2617
+ 'entry in the same slot already answers to. Rename the placeholder or the skin so the two differ. ' +
2618
+ `${collisions.join('. ')}`,
2619
+ );
2620
+ }
2621
+ return contested;
2622
+ }
2623
+
2624
+ /**
2625
+ * Give one attachment its own `name`, and pin the texture `path` that name would
2626
+ * otherwise have taken with it.
2627
+ *
2628
+ * 🚨 The second half is the whole hazard. `readAttachment` reads
2629
+ * `const name = getValue(map, "name", placeholder)` and then
2630
+ * `const path = getValue(map, "path", name)` (`SkeletonJson.ts:526-529`, and
2631
+ * again at `:559` for a mesh) — so `path` defaults to the NAME, not to the
2632
+ * placeholder. Writing a name and leaving `path` alone silently repoints the
2633
+ * attachment's texture lookup at a region no atlas has. Restating `path` at what
2634
+ * the attachment already resolved to makes the name change invisible to
2635
+ * everything but the editor's own uniqueness rule, which is the only thing it is
2636
+ * for.
2637
+ *
2638
+ * ⚠️ `region` and `mesh` are exactly the two types that read `path`; the polygon
2639
+ * types (`boundingbox`, `clipping`, `path`) have no texture and get the name
2640
+ * alone. The list is the parser's own two `getValue(map, "path", …)` sites
2641
+ * rather than a judgement about which attachments "have art".
2642
+ */
2643
+ function nameSkinAttachment(att: SpineAttachment, name: string, placeholder: string): SpineAttachment {
2644
+ const kind = (att as { type?: string }).type ?? 'region';
2645
+ // Key order is the parser's reading order — `name`, then `path`, then the rest
2646
+ // as the builder wrote it — for the same reason every other emitted object
2647
+ // follows it: the file is read by people and diffed against references.
2648
+ if (kind !== 'region' && kind !== 'mesh') return { name, ...att };
2649
+ const { path, ...rest } = att as SpineRegionAttachment | SpineMeshAttachment;
2650
+ return { name, path: path ?? placeholder, ...rest } as SpineAttachment;
2651
+ }
2652
+
2247
2653
  // ---------------------------------------------------------------------------
2248
2654
  // rig-declared attachments
2249
2655
  // ---------------------------------------------------------------------------
@@ -2613,13 +3019,50 @@ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentCont
2613
3019
  };
2614
3020
  }
2615
3021
 
3022
+ /**
3023
+ * The compiled image an attachment's `image` names — or a refusal that names the
3024
+ * file and says what is actually wrong with it.
3025
+ *
3026
+ * 🚨 This exists because of what the four call sites used to do instead, which
3027
+ * was nothing (issue #555). `ctx.images.find(...)` returning `undefined` fell
3028
+ * through to the size branch, so an attachment that named a PNG rigc had not
3029
+ * atlased was refused with *a region needs width and height — give them, or give
3030
+ * an "image" and rigc will measure the PNG*: the remedy sentence handed to the
3031
+ * one author who had already done both halves of it. The two mesh generators
3032
+ * said `no compiled image for "x.png"`, which names the file and not the fault.
3033
+ *
3034
+ * ⭐ What the message may not do is guess at the spec. Every image a skin
3035
+ * attachment names is measured and atlased, one per file, so an attachment
3036
+ * standing in front of a region that does not exist is rigc having skipped a
3037
+ * measurement — the author cannot repair it by writing anything. Saying so is
3038
+ * the whole difference between a message that ends a session and one that starts
3039
+ * a bug report, and it is why this refusal reads as an invariant rather than as
3040
+ * advice. The near-miss list is `resolveFromAtlas`'s, for the case where the
3041
+ * spec did misspell a name in a way something upstream let through.
3042
+ */
3043
+ function atlasedImage(image: string, where: string, ctx: AttachmentContext): CompiledImage {
3044
+ const region = basename(image, '.png');
3045
+ const img = ctx.images.find((im) => im.region === region);
3046
+ if (img) return img;
3047
+ const near = nearMisses(region, ctx.images.map((im) => im.region));
3048
+ const built = ctx.images.map((im) => im.region).sort();
3049
+ throw new CompileError(
3050
+ `${where}: the image "${image}" was never added to the atlas, so there is no region "${region}" for this ` +
3051
+ 'attachment to draw and no measurement of it to take a size from. Every image an attachment names is ' +
3052
+ 'measured and atlased — one per file, whichever skin names it (issue #555) — so reaching this means rigc ' +
3053
+ 'skipped one, not that the spec left anything out. ' +
3054
+ (near.length ? `The nearest region(s) built are ${near.map((n) => JSON.stringify(n)).join(', ')}. ` : '') +
3055
+ `The atlas holds ${built.length} region(s): ${built.join(', ')}`,
3056
+ );
3057
+ }
3058
+
2616
3059
  function buildRigRegion(
2617
3060
  att: RigRegionAttachment,
2618
3061
  placeholder: string,
2619
3062
  where: string,
2620
3063
  ctx: AttachmentContext,
2621
3064
  ): SpineRegionAttachment {
2622
- const img = att.image === undefined ? null : ctx.images.find((im) => im.region === basename(att.image!, '.png'));
3065
+ const img = att.image === undefined ? null : atlasedImage(att.image, where, ctx);
2623
3066
  // ⭐ An IMPORTED region's size is not a default the spec may override. On the
2624
3067
  // loose path `att.width` and the PNG's width are two legitimate numbers — "draw
2625
3068
  // this drawing at this size" is a scale, and the region covers its page either
@@ -2870,7 +3313,7 @@ function buildRigMesh(
2870
3313
  // nor measurable it is a refusal, as it is for a region: 0 is not a size the
2871
3314
  // spec stated, and the editor shows whatever is written here as the image's
2872
3315
  // dimensions (it showed 32x32, its missing-image placeholder, for a 0x0 mesh).
2873
- const img = att.image === undefined ? undefined : ctx.images.find((im) => im.region === basename(att.image!, '.png'));
3316
+ const img = att.image === undefined ? undefined : atlasedImage(att.image, where, ctx);
2874
3317
  const width = att.width ?? img?.width;
2875
3318
  const height = att.height ?? img?.height;
2876
3319
  if (width === undefined || height === undefined) {
@@ -3377,9 +3820,7 @@ function buildGridAttachment(
3377
3820
  );
3378
3821
  }
3379
3822
 
3380
- const region = basename(att.image, '.png');
3381
- const img = ctx.images.find((im) => im.region === region);
3382
- if (!img) throw new CompileError(`${where}: no compiled image for "${att.image}"`);
3823
+ const img = atlasedImage(att.image, where, ctx);
3383
3824
  const plate = partPlate(img);
3384
3825
 
3385
3826
  let geometry;
@@ -3460,7 +3901,7 @@ function buildGridAttachment(
3460
3901
  height: r6(h),
3461
3902
  };
3462
3903
  if (att.path !== undefined) out.path = att.path;
3463
- else if (region !== placeholder) out.path = region;
3904
+ else if (img.region !== placeholder) out.path = img.region;
3464
3905
  if (att.color !== undefined) out.color = att.color;
3465
3906
  return out;
3466
3907
  }
@@ -3503,9 +3944,7 @@ function buildContourAttachment(
3503
3944
  'there is nothing else here that says which pixels to measure',
3504
3945
  );
3505
3946
  }
3506
- const region = basename(att.image, '.png');
3507
- const img = ctx.images.find((im) => im.region === region);
3508
- if (!img) throw new CompileError(`${where}: no compiled image for "${att.image}"`);
3947
+ const img = atlasedImage(att.image, where, ctx);
3509
3948
  // ⚠️ Nothing here reads the PNG's colour type. `hasAlpha` answers "where does
3510
3949
  // this file keep its alpha", not "is any pixel of it transparent" — a tRNS
3511
3950
  // chunk is real transparency (issue #215) and an all-255 alpha channel is
@@ -3602,7 +4041,7 @@ function buildContourAttachment(
3602
4041
  // basename, so a placeholder named anything else needs `path` written down or
3603
4042
  // the loader resolves nothing.
3604
4043
  if (att.path !== undefined) out.path = att.path;
3605
- else if (region !== placeholder) out.path = region;
4044
+ else if (img.region !== placeholder) out.path = img.region;
3606
4045
  if (att.color !== undefined) out.color = att.color;
3607
4046
  return out;
3608
4047
  }
@@ -3704,7 +4143,12 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
3704
4143
  if (spec.type === 'ik') {
3705
4144
  out.bones = boneList();
3706
4145
  out.target = needBone(spec.target, 'target');
3707
- copy(['scaleY', 'mix', 'softness', 'bendPositive', 'compress', 'stretch', 'skin']);
4146
+ // `scaleY` is the `ScaleYMode` enum, not a number or a flag: the parser runs
4147
+ // it through `Utils.enumValue` (`:150`), which resolves an unknown name to
4148
+ // `undefined` and assigns it without a word. Out of `copy` for that reason —
4149
+ // see `RIG_SCALE_Y_MODES`.
4150
+ if (spec.scaleY !== undefined) out.scaleY = needEnum(spec.scaleY, 'scaleY', RIG_SCALE_Y_MODES);
4151
+ copy(['mix', 'softness', 'bendPositive', 'compress', 'stretch', 'skin']);
3708
4152
  return out;
3709
4153
  }
3710
4154
  if (spec.type === 'transform') {
@@ -4090,13 +4534,15 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): S
4090
4534
  }
4091
4535
  if (spec.type === 'physics') {
4092
4536
  out.bone = needBone(spec.bone, 'bone');
4537
+ // The same `ScaleYMode` enum an ik constraint carries, under the same key
4538
+ // and through the same silent `Utils.enumValue` (`:301`).
4539
+ if (spec.scaleY !== undefined) out.scaleY = needEnum(spec.scaleY, 'scaleY', RIG_SCALE_Y_MODES);
4093
4540
  copy([
4094
4541
  'x',
4095
4542
  'y',
4096
4543
  'rotate',
4097
4544
  'scaleX',
4098
4545
  'shearX',
4099
- 'scaleY',
4100
4546
  'limit',
4101
4547
  'fps',
4102
4548
  'inertia',
@@ -4199,6 +4645,7 @@ function buildRigInfo(
4199
4645
  meshKinds,
4200
4646
  meshSoftBones,
4201
4647
  deformMayFold,
4648
+ editorRoundTrip: rig.invariants?.editorRoundTrip === true,
4202
4649
  meshSlotBudget: rig.invariants?.meshSlots ?? null,
4203
4650
  meshTriangleBudget: rig.invariants?.meshTriangles ?? null,
4204
4651
  contactDepth,