spine-rigc 0.35.0 → 0.36.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
@@ -319,13 +319,14 @@ export function editorNamesInOrder(names: readonly string[], collection: 'animat
319
319
  }
320
320
 
321
321
  /**
322
- * The skin the editor keeps at index 0 whatever its name sorts as.
322
+ * The skin the editor keeps at index 0 whatever its name sorts as — and the one
323
+ * skin that may not share a placeholder with a named skin
324
+ * (`refuseDefaultSkinContest`).
323
325
  *
324
- * Exported for `ingest`, which has to know that a contested placeholder the
325
- * default skin fills is refused rather than named (`refuseDefaultSkinContest`),
326
- * and has no business spelling the name a second time (issue #746).
326
+ * Not exported since #796: `ingest` read it to know which contested entries got
327
+ * no composed name, and nothing is composed any more.
327
328
  */
328
- export const DEFAULT_SKIN = 'default';
329
+ const DEFAULT_SKIN = 'default';
329
330
 
330
331
  /**
331
332
  * The order the emitted `skins` array is written in: **`default` first, then the
@@ -400,7 +401,11 @@ function editorSkinOrder<T extends { name: string }>(skins: readonly T[]): T[] {
400
401
  * partly sorted:
401
402
  *
402
403
  * - a pair the comparator leaves open (`editorNameOrder`'s number, separator and
403
- * folder cases);
404
+ * folder cases). Since #791 a name carrying U+3000 or a full-width digit is
405
+ * not one: the comparator folds both first (`EDITOR_NAME_FOLD`), which is
406
+ * what a production map of 95 slot keys was measured to need, so such a map
407
+ * is sorted where it used to be kept whole — or, with only full-width digits
408
+ * in it, sorted with the digits at their code points;
404
409
  * - any slot name holding `/`. The folder rule was measured on skin and
405
410
  * animation names, which the editor files in folders; slots it files under
406
411
  * bones, and no round trip carried a slot name with a `/` in it.
@@ -465,10 +470,59 @@ interface ReadName {
465
470
  skipped: number;
466
471
  }
467
472
 
473
+ /**
474
+ * The characters the editor folds before it compares two names, as
475
+ * `[first, last, to]` code point ranges: each of `first`…`last` is read as
476
+ * `to` plus its offset from `first` (issue #791).
477
+ *
478
+ * 🔬 **Measured on a production set, not on the probes or `examples/`.** One
479
+ * skin's `attachments` map of 95 slot keys, 7 of them carrying U+3000
480
+ * IDEOGRAPHIC SPACE or a full-width digit (U+FF12, U+FF14), came back in an
481
+ * order this comparator reproduces **95 of 95** once U+3000 is read as U+0020
482
+ * and U+FF10–U+FF19 as `0`–`9` — an order neither a plain codepoint sort nor a
483
+ * case-insensitive sort without the fold reproduces. No name in the five probes, the
484
+ * twelve exports or any spec in this tree carries either character, so the set
485
+ * is the only measurement of them there is.
486
+ *
487
+ * ⚠️ **Exactly those two classes, and not `normalize('NFKC')`, although NFKC
488
+ * folds both the same way.** Three readings reproduce the measurement and
489
+ * disagree past it: NFKC (which also turns `fi` into `fi`, `²` and `①` into
490
+ * digits, U+00A0 and U+2000–U+200A into a space, and composes `e` + U+0301
491
+ * into `é`); a classifier that reads every Unicode decimal digit as a digit and
492
+ * every space separator as a space (which also reads `٣` as 3, and does none
493
+ * of NFKC's other folds); and a width fold alone (which also reads `A` as `A`,
494
+ * and folds no other space). What all three agree on is exactly U+3000 and the
495
+ * ten full-width digits — the ten are one class under every reading, so the
496
+ * eight the set did not carry are folded with the two it did — and that
497
+ * intersection is what is applied. Every other character is compared at its own
498
+ * code point as before, and a whitespace character that is not a space is still
499
+ * `leafOrder`'s `separator`.
500
+ *
501
+ * One comparator (#728), so the fold reaches `skins` and `animations` too: an
502
+ * animation name with a full-width digit was compared at the digit's code point,
503
+ * the order the only measurement of such a name contradicts, and a pair that
504
+ * U+3000 decided was refused as `separator` and is now ordered.
505
+ */
506
+ export const EDITOR_NAME_FOLD: ReadonlyArray<readonly [number, number, number]> = [
507
+ [0x3000, 0x3000, 0x0020],
508
+ [0xff10, 0xff19, 0x0030],
509
+ ];
510
+
511
+ /** `name` with every character `EDITOR_NAME_FOLD` covers read as the character it folds to. */
512
+ function foldBeforeComparing(name: string): string {
513
+ let out = '';
514
+ for (const char of name) {
515
+ const code = char.codePointAt(0) ?? 0;
516
+ const range = EDITOR_NAME_FOLD.find(([first, last]) => code >= first && code <= last);
517
+ out += range === undefined ? char : String.fromCodePoint(range[2] + code - range[0]);
518
+ }
519
+ return out;
520
+ }
521
+
468
522
  function readName(name: string, skip: RegExp): ReadName {
469
523
  const chars: string[] = [];
470
524
  let skipped = 0;
471
- for (const char of foldUp(name)) {
525
+ for (const char of foldUp(foldBeforeComparing(name))) {
472
526
  if (skip.test(char)) skipped++;
473
527
  else chars.push(char);
474
528
  }
@@ -2380,12 +2434,12 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2380
2434
  // `parseRigSpec` has refused a sequence on any kind but these three and
2381
2435
  // proved its shape, so what is left is whether its frames exist. The stem
2382
2436
  // is the region `path` the attachment resolves through, which with no
2383
- // `path` stated is the placeholder (`nameSkinAttachment` pins exactly that
2384
- // where a placeholder is contested).
2437
+ // `path` stated is the attachment's NAME — its stated `name`, else the
2438
+ // placeholder (`path = getValue(map, "path", name)`, issue #796).
2385
2439
  const sequence = (att as RigRegionAttachment).sequence;
2386
2440
  if (sequence !== undefined) {
2387
2441
  addSequenceFrames(
2388
- (att as RigRegionAttachment).path ?? placeholder,
2442
+ (att as RigRegionAttachment).path ?? (att as RigRegionAttachment).name ?? placeholder,
2389
2443
  sequence,
2390
2444
  `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`,
2391
2445
  );
@@ -2394,11 +2448,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2394
2448
  rigAttachmentNames.set(slotName, names);
2395
2449
  }
2396
2450
  }
2397
- // Which (slot, placeholder) pairs more than one skin fills — the pairs whose
2398
- // entries have to carry an attachment `name` of their own. Computed here, off
2399
- // the normalised skin table, so the slot loop below reads a decision rather
2400
- // than re-deriving one per attachment.
2401
- const contested = contestedPlaceholders(skinNames, skinParts);
2451
+ // The one contested shape the editor cannot hold — a placeholder the default
2452
+ // skin shares with a named skin — refused here, off the normalised skin
2453
+ // table, before anything is built. Every other contested placeholder is
2454
+ // emitted exactly as the spec states it (issue #796).
2455
+ refuseDefaultSkinContests(skinNames, skinParts);
2402
2456
 
2403
2457
  // -- 2. atlas --------------------------------------------------------------
2404
2458
  //
@@ -2624,7 +2678,6 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2624
2678
  const placeholders = skinParts.get(skinName)!.attachments[rigSlot.name];
2625
2679
  if (!placeholders) continue;
2626
2680
  const perSlot: Record<string, SpineAttachment> = {};
2627
- const shared = contested.get(rigSlot.name);
2628
2681
  for (const [placeholder, att] of Object.entries(placeholders)) {
2629
2682
  const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
2630
2683
  const built = buildRigAttachment(att, placeholder, where, {
@@ -2641,15 +2694,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2641
2694
  slotNames: new Set(rig.slots.map((s) => s.name)),
2642
2695
  links: pendingLinks,
2643
2696
  });
2644
- // The name is put on AFTER the builder rather than inside it: five
2645
- // builders write five shapes, the rule is one rule, and a rule that has
2646
- // to be remembered in five places is a rule that will be kept in four.
2647
- // `null` is an entry that carries no `name` field, which is every
2648
- // uncontested placeholder. A contested one the DEFAULT skin fills never
2649
- // reaches here: it is refused above (issue #567), because the editor
2650
- // holds no such shape in either spelling.
2651
- const composed = composeSkinAttachmentName(skinName, placeholder, shared?.has(placeholder) === true);
2652
- perSlot[placeholder] = composed === null ? built : nameSkinAttachment(built, composed, placeholder);
2697
+ // The name is put on AFTER the builder rather than inside it: six
2698
+ // builders write six shapes, the rule is one rule, and a rule that has
2699
+ // to be remembered in six places is a rule that will be kept in five.
2700
+ // It is the spec's own `name`, or nothing (issue #796).
2701
+ perSlot[placeholder] = withStatedName(built, att);
2653
2702
  }
2654
2703
  tableFor(skinName)[rigSlot.name] = perSlot;
2655
2704
  }
@@ -3444,49 +3493,63 @@ function rotationOf(spec: RigBone, ctx: BoneContext): number | null {
3444
3493
  }
3445
3494
 
3446
3495
  // ---------------------------------------------------------------------------
3447
- // attachment names, where one placeholder holds several attachments
3496
+ // attachment names, and the one placeholder shape the editor cannot hold
3448
3497
  // ---------------------------------------------------------------------------
3449
3498
 
3450
3499
  /**
3451
- * What separates a skin's name from a placeholder's inside an attachment name.
3452
- *
3453
- * ⚠️ A separator is the one part of this that could collide with a name somebody
3454
- * wrote, so it is measured rather than picked: across the **160** distinct
3455
- * placeholder names and **159** distinct atlas region names in `examples/` and
3456
- * `gallery/` — every editor-authored name this repository has — `/` occurs in
3457
- * **0**, while `-` occurs in 85 / 86 and `_` in 37 / 37. It is also the character
3458
- * the format already has a structure for, since an attachment's name doubles as
3459
- * its texture path and a path is what `/` separates.
3460
- *
3461
- * 🔒 And the choice is not load-bearing anyway, which is the point of stating it
3462
- * this way: `contestedPlaceholders` refuses the build if the name it composes is
3463
- * one some other placeholder in the same slot already answers to. A separator
3464
- * nobody uses makes that refusal rare; the refusal is what makes it safe.
3465
- */
3466
- const SKIN_ATTACHMENT_SEPARATOR = '/';
3467
-
3468
- function skinAttachmentName(skinName: string, placeholder: string): string {
3469
- return `${skinName}${SKIN_ATTACHMENT_SEPARATOR}${placeholder}`;
3470
- }
3471
-
3472
- /**
3473
- * The `name` one skin's entry for a placeholder is emitted with, or `null` for
3474
- * the entries that carry no `name` field at all.
3475
- *
3476
- * Stated once, and called by both the emit and `contestedPlaceholders`'
3477
- * collision walk, because two readings of one rule is how issue #567 happened.
3478
- * By the time either caller runs, a contested placeholder the **default** skin
3479
- * fills has already been refused — see `refuseDefaultSkinContest` — so every
3480
- * entry this composes for is a named skin's.
3481
- *
3482
- * 🔒 A third caller reads it from outside: `ingest` compares the name a source
3483
- * states against the one this returns, and reports the rename where the two
3484
- * differ (issue #746). It calls this rather than `skinAttachmentName` so that
3485
- * WHETHER a name is composed is read off the same line as WHAT it is — the
3486
- * separator and the contest test are each stated once, here.
3500
+ * An attachment as the builder made it, with the spec's own `name` put on it —
3501
+ * or unchanged, where the spec states none.
3502
+ *
3503
+ * ## rigc derives no name (issue #796)
3504
+ *
3505
+ * `readAttachment` reads `name = getValue(map, "name", placeholder)`
3506
+ * (`SkeletonJson.js:526`), so an entry that states no `name` is named by its
3507
+ * placeholder and one that states a name is named by it: the runtime's
3508
+ * `Attachment.name`, which a consumer reads off `slot.attachment.name`. That is
3509
+ * the whole of what the field does. Nothing in the format resolves by it — a
3510
+ * skin's table, a slot's setup `attachment`, an attachment or deform timeline and
3511
+ * a linked mesh's `source` are keyed by the placeholder (`:415-418`, `:433`,
3512
+ * `:1140`) — so the field is emitted exactly when the spec states it, verbatim,
3513
+ * and never composed.
3514
+ *
3515
+ * 🚨 **It used to be composed, and the composition was measured wrong on
3516
+ * production data.** Issue #541 (landed by #552) wrote `"<skin>/<placeholder>"`
3517
+ * for every placeholder more than one skin fills, on the reading that *"the
3518
+ * editor requires attachment names to be unique — because a linked mesh resolves
3519
+ * its parent by name"*. Both halves fail a measurement:
3520
+ *
3521
+ * - **A linked mesh resolves its source by skin, slot and KEY.** Measured through
3522
+ * spine-core 4.3.13: two skins each fill placeholder `C` with a different mesh
3523
+ * and no `name`, both load named `C`, and a link in the second skin with
3524
+ * `source: "C", skin: <second>` binds the SECOND skin's mesh; the same link
3525
+ * with `skin: <first>` binds the first. A link whose `source` spells its
3526
+ * source's stated name rather than its key throws `Source mesh not found`.
3527
+ * - **The editor imports the uncomposed shape.** A production rig whose
3528
+ * placeholders are each filled by two named skins, meshes and linked meshes
3529
+ * among them, imported through Spine 4.3.26 with every composed name stripped
3530
+ * — exit 0 and a project written (#796). It is the shape that editor exported
3531
+ * in the first place.
3532
+ *
3533
+ * What #541 actually bisected was a rig whose DEFAULT skin filled the contested
3534
+ * placeholder beside the named ones (`default` + one named skin was its smallest
3535
+ * refusing variant), and that shape is refused here on its own measurement —
3536
+ * `refuseDefaultSkinContest` — which never depended on the composition.
3537
+ *
3538
+ * ⚠️ `path` is not touched. It stays the builder's — stated, or derived from
3539
+ * `image` against the name the attachment will carry (`attachmentPath`) — and
3540
+ * its default is the parser's, `path = getValue(map, "path", name)`
3541
+ * (`:529`, `:560`), so a stated name with no `path` resolves the region that
3542
+ * name spells.
3543
+ *
3544
+ * 🔸 `name` is written FIRST: the editor writes it ahead of `type` on every
3545
+ * named attachment of the production set #796 was measured on (all of them
3546
+ * meshes), and it is the key the parser reads first. No region, polygon or link
3547
+ * with a name was in that set, so for those types the position is the same
3548
+ * choice rather than a second measurement.
3487
3549
  */
3488
- export function composeSkinAttachmentName(skinName: string, placeholder: string, contested: boolean): string | null {
3489
- return contested ? skinAttachmentName(skinName, placeholder) : null;
3550
+ function withStatedName(att: SpineAttachment, stated: RigAttachment): SpineAttachment {
3551
+ const name = (stated as { name?: string }).name;
3552
+ return name === undefined ? att : ({ name, ...att } as SpineAttachment);
3490
3553
  }
3491
3554
 
3492
3555
  /**
@@ -3537,12 +3600,13 @@ export function composeSkinAttachmentName(skinName: string, placeholder: string,
3537
3600
  * other. There is no third spelling to find, so this is a `CompileError` and not
3538
3601
  * a scheme, in the shape issue #543 used: refuse by name and say what to do.
3539
3602
  *
3540
- * ⚠️ What this does NOT touch, and the trips measured that half too: a
3541
- * placeholder that two or more NAMED skins fill keeps #552's composition
3542
- * exactly. Trip 8's second rig — two named skins filling `patch`, the default
3543
- * skin holding `block` only — imported, exported and measured **0.0000 mean
3544
- * MAE**, names and paths intact. The remedy this refusal states is that rig:
3545
- * move the default skin's entry into a named skin.
3603
+ * 🔒 **Why it outlived the composition it was written beside (#796).** Both
3604
+ * trips are about the default skin's entry, and neither needs a name to be
3605
+ * composed for anything to fail: trip 8 is the uncomposed spelling, and trip 7
3606
+ * is exactly what a spec stating a `name` on that entry would now emit. So the
3607
+ * refusal stands on its own two measurements, and a stated `name` does not lift
3608
+ * it. What it never covered — two or more NAMED skins filling one placeholder —
3609
+ * is the shape the editor exports itself, and it is emitted as stated.
3546
3610
  */
3547
3611
  function refuseDefaultSkinContest(slotName: string, placeholder: string, skins: readonly string[]): never {
3548
3612
  const named = skins.filter((skin) => skin !== DEFAULT_SKIN);
@@ -3550,78 +3614,28 @@ function refuseDefaultSkinContest(slotName: string, placeholder: string, skins:
3550
3614
  `slot "${slotName}": placeholder "${placeholder}" is filled by the "${DEFAULT_SKIN}" skin AND by ` +
3551
3615
  `${named.length === 1 ? 'skin' : 'skins'} ${named.map((skin) => `"${skin}"`).join(', ')}, and the Spine ` +
3552
3616
  'editor has no way to hold that. Measured on 4.3.26 in both spellings: give the default skin\'s attachment a ' +
3553
- `name of its own ("${DEFAULT_SKIN}${SKIN_ATTACHMENT_SEPARATOR}${placeholder}") and the editor re-keys it by ` +
3554
- `that name on export, so the slot's setup attachment "${placeholder}" resolves in no default-skin key and the ` +
3555
- 'default skin draws nothing; leave it as the placeholder and the import is refused outright with ' +
3617
+ 'name of its own and the editor re-keys it by that name on export, so the slot\'s setup attachment ' +
3618
+ `"${placeholder}" resolves in no default-skin key and the default skin draws nothing; leave it as the ` +
3619
+ 'placeholder and the import is refused outright with ' +
3556
3620
  `"Multiple attachments have the same name: ${placeholder} ${placeholder}", because a default-skin attachment ` +
3557
3621
  "hangs on the slot beside the named skins' placeholder of that name. Move the default skin's entry for this " +
3558
3622
  `slot into a named skin — call it "base" — so every skin filling "${placeholder}" is a named one. Two or more ` +
3559
- 'named skins sharing a placeholder is the shape the editor does hold, and rigc composes their names for them ' +
3560
- '(#541, #552).',
3623
+ 'named skins sharing a placeholder is the shape the editor exports itself, and it needs no `name` (#796).',
3561
3624
  );
3562
3625
  }
3563
3626
 
3564
3627
  /**
3565
- * Which `(slot, placeholder)` pairs more than one skin fills — and, on the way,
3566
- * the refusal that keeps the composed names from colliding with authored ones.
3567
- *
3568
- * ## The defect this exists for
3569
- *
3570
- * Two skins putting different art under one placeholder is what a skin IS, and
3571
- * until issue #541 rigc emitted both entries with no `name`, which makes the
3572
- * placeholder the name of both (`SkeletonJson.ts:526`). spine-core is happy —
3573
- * its skin table is keyed by placeholder, so the two never meet. The Spine
3574
- * editor refuses the whole import, and says exactly why:
3575
- *
3576
- * ERROR: Unable to import skeleton.
3577
- * [error] Error reading skeleton: skins
3578
- * Cause: [error] Error reading attachment: patch (MOw)
3579
- * Cause: [error] Multiple attachments have the same name: patch patch
3580
- *
3581
- * Bisected on the emitted file: four skins REFUSED, deform timelines removed
3582
- * REFUSED, `default` + one skin REFUSED, `default` alone IMPORTS, the second skin
3583
- * given a distinct placeholder IMPORTS, and the same placeholder with **each
3584
- * entry given its own `name`** IMPORTS — all four skins. So it is neither the
3585
- * skin count nor the timelines; it is one name over several attachments.
3586
- *
3587
- * ## Only the contested pairs are named, and the default skin may not contest
3588
- *
3589
- * A placeholder one skin fills keeps the emitted shape it has always had: no
3590
- * `name`, no `path` it did not already carry. Every rig in this tree declares
3591
- * exactly one skin, so **no emitted byte in the tree moves** — and a
3592
- * multi-skin rig whose skins use distinct placeholders does not move either,
3593
- * because nothing there is ambiguous to begin with.
3594
- *
3595
- * ⚠️ A contested placeholder the **default** skin fills is refused before any
3596
- * of this runs — `refuseDefaultSkinContest`, issue #567 — because two editor
3597
- * round trips showed the editor holds no such shape in either spelling. So
3598
- * every entry the walk below composes for belongs to a named skin, and the
3599
- * emitted name comes off `composeSkinAttachmentName`, which this function calls
3600
- * rather than restates: the emit and the refusal disagreeing about one name is
3601
- * the defect both of them exist to prevent.
3602
- *
3603
- * ⚠️ The scope of the editor's uniqueness rule is **not** skeleton-wide, and the
3604
- * corpus proves it rather than a hypothesis doing so: `spineboy-pro.json`, which
3605
- * the editor wrote, gives the name `head` to a region in slot `head` and to a
3606
- * bounding box in slot `head-bb`, and names one `hoverglow-small` across eight
3607
- * slots. What #541 refused was one slot. Composing from the skin makes the names
3608
- * unique within the slot, which satisfies that scope and every narrower one;
3609
- * nothing here claims to know which of them the editor actually applies, and an
3610
- * assertion that policed the emitted artifact would have to.
3611
- *
3612
- * ## Why a composed name is not the compiler inventing a value
3613
- *
3614
- * rigc has always decided this attachment's name — it decided it was the
3615
- * placeholder, silently, and that decision is the defect. What changes is the
3616
- * derivation, not who makes it, and the new one is a function of two names the
3617
- * spec wrote. Nothing is read off the art, and `path` — the field that says
3618
- * which texture to draw — stays exactly what the spec stated or what the
3619
- * attachment already resolved to.
3628
+ * Walk every `(slot, placeholder)` pair more than one skin fills, and refuse the
3629
+ * one shape among them the editor has no representation for: a pair the
3630
+ * **default** skin is one of the fillers of (`refuseDefaultSkinContest`).
3631
+ *
3632
+ * Every other contested pair is legal as it stands and is emitted exactly as
3633
+ * the spec states it — the same placeholder key in each skin, each entry named
3634
+ * by its own `name` or by that key (issue #796). This function decides nothing
3635
+ * about names; until #796 it also composed and collision-checked
3636
+ * `<skin>/<placeholder>`, and both went with the composition.
3620
3637
  */
3621
- function contestedPlaceholders(
3622
- skinNames: readonly string[],
3623
- skinParts: Map<string, RigSkinParts>,
3624
- ): Map<string, Set<string>> {
3638
+ function refuseDefaultSkinContests(skinNames: readonly string[], skinParts: Map<string, RigSkinParts>): void {
3625
3639
  /** slot -> placeholder -> the skins that fill it, in declaration order. */
3626
3640
  const fillers = new Map<string, Map<string, string[]>>();
3627
3641
  for (const skinName of skinNames) {
@@ -3633,82 +3647,11 @@ function contestedPlaceholders(
3633
3647
  fillers.set(slotName, perSlot);
3634
3648
  }
3635
3649
  }
3636
- const contested = new Map<string, Set<string>>();
3637
- const collisions: string[] = [];
3638
3650
  for (const [slotName, perSlot] of fillers) {
3639
- const shared = new Set([...perSlot].filter(([, skins]) => skins.length > 1).map(([placeholder]) => placeholder));
3640
- // 🚨 Before anything is composed: a contested placeholder the DEFAULT skin
3641
- // fills has no representation in the editor at all, in either spelling
3642
- // (issue #567, round trips 7 and 8). It is refused here rather than emitted,
3643
- // and the refusal comes first because renaming cannot repair it — the
3644
- // remedy is a different rig, not a different string.
3645
- for (const placeholder of shared) {
3646
- const skins = perSlot.get(placeholder)!;
3647
- if (skins.includes(DEFAULT_SKIN)) refuseDefaultSkinContest(slotName, placeholder, skins);
3648
- }
3649
- if (shared.size) contested.set(slotName, shared);
3650
- /** Emitted attachment name -> the first entry that claimed it. */
3651
- const claimed = new Map<string, string>();
3652
3651
  for (const [placeholder, skins] of perSlot) {
3653
- for (const skinName of skins) {
3654
- // The emitted name, read off the one function that decides it — so the
3655
- // refusal and the emit cannot drift into two readings. An UNCONTESTED
3656
- // entry is claimed under its bare placeholder, the default skin's
3657
- // included: a named skin whose composed name equals it is a collision,
3658
- // and one this walk sees for the same reason it sees every other.
3659
- const name = composeSkinAttachmentName(skinName, placeholder, shared.has(placeholder)) ?? placeholder;
3660
- const site = `skin "${skinName}" placeholder "${placeholder}"`;
3661
- const taken = claimed.get(name);
3662
- if (taken === undefined) claimed.set(name, site);
3663
- else collisions.push(`slot "${slotName}": ${taken} and ${site} would both be named "${name}"`);
3664
- }
3652
+ if (skins.length > 1 && skins.includes(DEFAULT_SKIN)) refuseDefaultSkinContest(slotName, placeholder, skins);
3665
3653
  }
3666
3654
  }
3667
- if (collisions.length) {
3668
- throw new CompileError(
3669
- `${collisions.length} attachment name collision(s): a placeholder that more than one skin fills is emitted ` +
3670
- `with the name "<skin>${SKIN_ATTACHMENT_SEPARATOR}<placeholder>", because the Spine editor refuses an import ` +
3671
- 'in which one slot holds two attachments of one name (#541) — and here that composed name is one another ' +
3672
- 'entry in the same slot already answers to. Rename the placeholder or the skin so the two differ. ' +
3673
- `${collisions.join('. ')}`,
3674
- );
3675
- }
3676
- return contested;
3677
- }
3678
-
3679
- /**
3680
- * Give one attachment its own `name`, and pin the texture `path` that name would
3681
- * otherwise have taken with it.
3682
- *
3683
- * 🚨 The second half is the whole hazard. `readAttachment` reads
3684
- * `const name = getValue(map, "name", placeholder)` and then
3685
- * `const path = getValue(map, "path", name)` (`SkeletonJson.ts:526-529`, and
3686
- * again at `:559` for a mesh) — so `path` defaults to the NAME, not to the
3687
- * placeholder. Writing a name and leaving `path` alone silently repoints the
3688
- * attachment's texture lookup at a region no atlas has. Restating `path` at what
3689
- * the attachment already resolved to makes the name change invisible to
3690
- * everything but the editor's own uniqueness rule, which is the only thing it is
3691
- * for.
3692
- *
3693
- * ⚠️ `region` and `mesh` are exactly the two types that read `path`; the polygon
3694
- * types (`boundingbox`, `clipping`, `path`) have no texture and get the name
3695
- * alone. The list is the parser's own two `getValue(map, "path", …)` sites
3696
- * rather than a judgement about which attachments "have art".
3697
- */
3698
- function nameSkinAttachment(att: SpineAttachment, name: string, placeholder: string): SpineAttachment {
3699
- const kind = (att as { type?: string }).type ?? 'region';
3700
- // Key order is the parser's reading order — `name`, then `path`, then the rest
3701
- // as the builder wrote it — for the same reason every other emitted object
3702
- // follows it: the file is read by people and diffed against references.
3703
- //
3704
- // ⚠️ The three kinds here are the three the parser gives a texture `path` to,
3705
- // and `linkedmesh` is one of them (`SkeletonJson.ts:541`, `:570` — the mesh
3706
- // branch is shared). Leaving it out would write a `name` and no `path`, and
3707
- // `path` defaults to `name`, so a contested link would resolve the region
3708
- // "<skin>/<placeholder>", which no atlas holds.
3709
- if (kind !== 'region' && kind !== 'mesh' && kind !== 'linkedmesh') return { name, ...att };
3710
- const { path, ...rest } = att as SpineRegionAttachment | SpineMeshAttachment | SpineLinkedMeshAttachment;
3711
- return { name, path: path ?? placeholder, ...rest } as SpineAttachment;
3712
3655
  }
3713
3656
 
3714
3657
  // ---------------------------------------------------------------------------
@@ -3845,6 +3788,20 @@ function buildRigAttachment(
3845
3788
  );
3846
3789
  }
3847
3790
  const type = stated ?? 'region';
3791
+ // `name` is the runtime's `Attachment.name` and, on a type that draws, what
3792
+ // `path` defaults to — so it is written verbatim and has to be a string the
3793
+ // parser can hold as one (issue #796). An empty string is a string: it names
3794
+ // the attachment "" and, with no `path`, asks the atlas for region "", which
3795
+ // `A08` names by region like any other miss.
3796
+ const statedName = (att as { name?: unknown }).name;
3797
+ if (statedName !== undefined && typeof statedName !== 'string') {
3798
+ throw new CompileError(
3799
+ `${where}: "name" is ${JSON.stringify(statedName) ?? String(statedName)}, which is not a string. An ` +
3800
+ "attachment's name is the runtime's `Attachment.name` — `getValue(map, \"name\", placeholder)`, " +
3801
+ '`SkeletonJson.js:526` — and on a region, mesh or linked mesh it is also what `path` defaults to. Write it ' +
3802
+ 'as a string, or leave the key out and the attachment is named by its placeholder.',
3803
+ );
3804
+ }
3848
3805
  // A linked mesh in the format's OTHER spelling. `type: "mesh"` and `type:
3849
3806
  // "linkedmesh"` share one branch and the `source` key is what decides between
3850
3807
  // them (`:568-569`, `:582`; SPEC_COVERAGE part 1-6) — so a mesh carrying
@@ -4285,11 +4242,42 @@ function atlasedImage(image: string, where: string, ctx: AttachmentContext): Com
4285
4242
  * through one line of parser, and it would have had to contradict §3.4 rather
4286
4243
  * than satisfy it.
4287
4244
  */
4288
- function attachmentPath(att: { path?: string; image?: string }, placeholder: string): string | undefined {
4245
+ function attachmentPath(att: { name?: string; path?: string; image?: string }, placeholder: string): string | undefined {
4289
4246
  if (att.path !== undefined) return att.path;
4290
4247
  if (att.image === undefined) return undefined;
4291
4248
  const region = basename(att.image, '.png');
4292
- return region === placeholder ? undefined : region;
4249
+ // Against the NAME the attachment will carry, because that is what `path`
4250
+ // defaults to — the stated `name`, else the placeholder (issue #796). Read
4251
+ // against the placeholder alone, an image named after a stated name would
4252
+ // restate it as `path`, a field the source never wrote.
4253
+ return region === (att.name ?? placeholder) ? undefined : region;
4254
+ }
4255
+
4256
+ /**
4257
+ * A mesh attachment's `path` and `color`, in that order — the two keys every
4258
+ * mesh constructor spreads right after `type` (issue #791).
4259
+ *
4260
+ * 🔬 The editor writes a mesh `type, path, color, uvs, …`: right after `type`,
4261
+ * `path` before `color` — measured on a production set (#791), not on
4262
+ * `examples/`. There 18 meshes whose `path` differs from their name wrote it
4263
+ * second and 1 mesh with a `color` wrote that second; no object carried both, so
4264
+ * `path` before `color` is the order the two measured positions leave, not a
4265
+ * third measurement. The twelve exports carry neither key on a mesh, which is
4266
+ * why `EDITOR_KEY_ORDER`'s `mesh attachment` row does not list them: an unlisted
4267
+ * key keeps its constructor's index, so the position is stated HERE, and the
4268
+ * row stays what the public exports derive.
4269
+ *
4270
+ * ⚠️ Not a linked mesh's, and not a region's. Neither was in the measured set —
4271
+ * a region's `path` stays where `buildRigRegion` puts it, a linked mesh's
4272
+ * keys where `buildRigLinkedMesh` does — because the analogy to a mesh is
4273
+ * obvious and it is not a measurement.
4274
+ */
4275
+ function meshTextureKeys(att: { name?: string; path?: string; image?: string; color?: string }, placeholder: string): Pick<SpineMeshAttachment, 'path' | 'color'> {
4276
+ const out: Pick<SpineMeshAttachment, 'path' | 'color'> = {};
4277
+ const path = attachmentPath(att, placeholder);
4278
+ if (path !== undefined) out.path = path;
4279
+ if (att.color !== undefined) out.color = att.color;
4280
+ return out;
4293
4281
  }
4294
4282
 
4295
4283
  /**
@@ -4328,8 +4316,22 @@ function emitSequence(seq: RigSequence): SpineSequence {
4328
4316
  * measures the same: that is the one number the frames state. Frames of
4329
4317
  * different sizes state several, and picking one — the first, the setup frame,
4330
4318
  * the largest — would be the compiler choosing a value the spec did not.
4331
- * Under `--atlas-in` a stated size that disagrees with a frame is refused as it
4332
- * is for a single region, for the same reason: the pack's rectangle is fixed.
4319
+ *
4320
+ * ⭐ A STATED size is emitted as stated and compared with no frame, on either
4321
+ * route. It is the quad, and the frames are what is drawn into it:
4322
+ * `computeUVs` scales each region by `width / region.originalWidth`, so a frame
4323
+ * of another size is drawn at the attachment's size, filling it (issue #795,
4324
+ * measured on spine-core 4.3.13: frames of 40, 60 and 80 under a stated 40 give
4325
+ * every frame the same ±20 quad, each with its own region's UVs). That is the
4326
+ * shape an editor exports whenever a series mixes image sizes — it writes the
4327
+ * setup frame's size and leaves the others as they are.
4328
+ *
4329
+ * ⚠️ The single-region rule in `buildRigRegion` does not transfer, though it
4330
+ * reads the same. There the attachment has one region, the editor always writes
4331
+ * that region's own size, and a spec stating another is contradicting the pack
4332
+ * it is resolved against. Here the attachment has `count` regions and one size,
4333
+ * so every frame but the setup frame differing from it is the ordinary case, and
4334
+ * refusing it refused correct editor exports.
4333
4335
  */
4334
4336
  function sequenceFrameSize(
4335
4337
  frames: readonly CompiledImage[],
@@ -4340,14 +4342,6 @@ function sequenceFrameSize(
4340
4342
  for (const field of ['width', 'height'] as const) {
4341
4343
  const sizes = [...new Set(frames.map((img) => img[field]))];
4342
4344
  if (stated[field] !== undefined) {
4343
- const packed = frames.find((img) => img.atlas !== undefined && img[field] !== stated[field]);
4344
- if (packed !== undefined) {
4345
- throw new CompileError(
4346
- `${where}: the spec says ${field} ${stated[field]} and sequence frame "${packed.region}" of the imported ` +
4347
- `atlas is ${packed[field]}; a packed frame's rectangle is fixed, so the two would produce a quad the ` +
4348
- 'pack cannot fill',
4349
- );
4350
- }
4351
4345
  out[field] = stated[field];
4352
4346
  } else if (sizes.length === 1) {
4353
4347
  out[field] = sizes[0];
@@ -4365,7 +4359,8 @@ function sequenceFrameSize(
4365
4359
  /** Every frame of `att`'s sequence, already atlased by the gather pass. */
4366
4360
  function sequenceFrames(att: { path?: string; sequence?: RigSequence }, placeholder: string, where: string, ctx: AttachmentContext): CompiledImage[] {
4367
4361
  const seq = att.sequence!;
4368
- const stem = att.path ?? placeholder;
4362
+ // `path`, else what `path` defaults to: the stated `name`, else the placeholder (issue #796).
4363
+ const stem = att.path ?? (att as { name?: string }).name ?? placeholder;
4369
4364
  const frames: CompiledImage[] = [];
4370
4365
  for (let i = 0; i < seq.count; i++) {
4371
4366
  // By the frame's FULL region name — `atlasedImage` takes a basename, and a
@@ -4707,6 +4702,7 @@ function buildRigMesh(
4707
4702
  }
4708
4703
  const out: SpineMeshAttachment = {
4709
4704
  type: 'mesh',
4705
+ ...meshTextureKeys(att, placeholder),
4710
4706
  uvs: att.uvs.map(f32),
4711
4707
  triangles: att.triangles,
4712
4708
  vertices,
@@ -4715,9 +4711,6 @@ function buildRigMesh(
4715
4711
  width: f32(width),
4716
4712
  height: f32(height),
4717
4713
  };
4718
- const path = attachmentPath(att, placeholder);
4719
- if (path !== undefined) out.path = path;
4720
- if (att.color !== undefined) out.color = att.color;
4721
4714
  if (att.sequence !== undefined) out.sequence = emitSequence(att.sequence);
4722
4715
  // Register it as `authored`: geometry rigc did not build and whose topology it
4723
4716
  // therefore gets to assume nothing about. The generator-topology assertions
@@ -4998,6 +4991,7 @@ function buildGeneratedMesh(
4998
4991
  });
4999
4992
  const out: SpineMeshAttachment = {
5000
4993
  type: 'mesh',
4994
+ ...meshTextureKeys(att, placeholder),
5001
4995
  uvs: geometry.uvs.map(f32),
5002
4996
  triangles: geometry.triangles,
5003
4997
  vertices: vertices.map(f32),
@@ -5005,9 +4999,6 @@ function buildGeneratedMesh(
5005
4999
  width: f32(w),
5006
5000
  height: f32(h),
5007
5001
  };
5008
- const path = attachmentPath(att, placeholder);
5009
- if (path !== undefined) out.path = path;
5010
- if (att.color !== undefined) out.color = att.color;
5011
5002
  return out;
5012
5003
  }
5013
5004
 
@@ -5565,6 +5556,7 @@ function buildGridAttachment(
5565
5556
  });
5566
5557
  const out: SpineMeshAttachment = {
5567
5558
  type: 'mesh',
5559
+ ...meshTextureKeys(att, placeholder),
5568
5560
  uvs: geometry.uvs.map(f32),
5569
5561
  triangles: geometry.triangles,
5570
5562
  vertices: vertices.map(f32),
@@ -5572,9 +5564,6 @@ function buildGridAttachment(
5572
5564
  width: f32(w),
5573
5565
  height: f32(h),
5574
5566
  };
5575
- const path = attachmentPath(att, placeholder);
5576
- if (path !== undefined) out.path = path;
5577
- if (att.color !== undefined) out.color = att.color;
5578
5567
  return out;
5579
5568
  }
5580
5569
 
@@ -5726,8 +5715,15 @@ function buildContourAttachment(
5726
5715
  holePixels: geometry.contour?.holePixels,
5727
5716
  depth: depth?.summary,
5728
5717
  });
5718
+ // Same rule a region attachment follows: the atlas region is the PNG's
5719
+ // basename, so a placeholder named anything else needs `path` written down or
5720
+ // the loader resolves nothing. Stated once in `attachmentPath` since #577 —
5721
+ // this comment used to be the rule's only statement, beside four emit sites
5722
+ // that disagreed with it — and spread right after `type` by
5723
+ // `meshTextureKeys` since #791.
5729
5724
  const out: SpineMeshAttachment = {
5730
5725
  type: 'mesh',
5726
+ ...meshTextureKeys(att, placeholder),
5731
5727
  uvs: geometry.uvs.map(f32),
5732
5728
  triangles: geometry.triangles,
5733
5729
  vertices: vertices.map(f32),
@@ -5735,14 +5731,6 @@ function buildContourAttachment(
5735
5731
  width: f32(w),
5736
5732
  height: f32(h),
5737
5733
  };
5738
- // Same rule a region attachment follows: the atlas region is the PNG's
5739
- // basename, so a placeholder named anything else needs `path` written down or
5740
- // the loader resolves nothing. Stated once in `attachmentPath` since #577 —
5741
- // this comment used to be the rule's only statement, beside four emit sites
5742
- // that disagreed with it.
5743
- const path = attachmentPath(att, placeholder);
5744
- if (path !== undefined) out.path = path;
5745
- if (att.color !== undefined) out.color = att.color;
5746
5734
  return out;
5747
5735
  }
5748
5736