spine-rigc 0.35.1 → 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
@@ -2433,12 +2434,12 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2433
2434
  // `parseRigSpec` has refused a sequence on any kind but these three and
2434
2435
  // proved its shape, so what is left is whether its frames exist. The stem
2435
2436
  // is the region `path` the attachment resolves through, which with no
2436
- // `path` stated is the placeholder (`nameSkinAttachment` pins exactly that
2437
- // 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).
2438
2439
  const sequence = (att as RigRegionAttachment).sequence;
2439
2440
  if (sequence !== undefined) {
2440
2441
  addSequenceFrames(
2441
- (att as RigRegionAttachment).path ?? placeholder,
2442
+ (att as RigRegionAttachment).path ?? (att as RigRegionAttachment).name ?? placeholder,
2442
2443
  sequence,
2443
2444
  `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`,
2444
2445
  );
@@ -2447,11 +2448,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2447
2448
  rigAttachmentNames.set(slotName, names);
2448
2449
  }
2449
2450
  }
2450
- // Which (slot, placeholder) pairs more than one skin fills — the pairs whose
2451
- // entries have to carry an attachment `name` of their own. Computed here, off
2452
- // the normalised skin table, so the slot loop below reads a decision rather
2453
- // than re-deriving one per attachment.
2454
- 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);
2455
2456
 
2456
2457
  // -- 2. atlas --------------------------------------------------------------
2457
2458
  //
@@ -2677,7 +2678,6 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2677
2678
  const placeholders = skinParts.get(skinName)!.attachments[rigSlot.name];
2678
2679
  if (!placeholders) continue;
2679
2680
  const perSlot: Record<string, SpineAttachment> = {};
2680
- const shared = contested.get(rigSlot.name);
2681
2681
  for (const [placeholder, att] of Object.entries(placeholders)) {
2682
2682
  const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
2683
2683
  const built = buildRigAttachment(att, placeholder, where, {
@@ -2694,15 +2694,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2694
2694
  slotNames: new Set(rig.slots.map((s) => s.name)),
2695
2695
  links: pendingLinks,
2696
2696
  });
2697
- // The name is put on AFTER the builder rather than inside it: five
2698
- // builders write five shapes, the rule is one rule, and a rule that has
2699
- // to be remembered in five places is a rule that will be kept in four.
2700
- // `null` is an entry that carries no `name` field, which is every
2701
- // uncontested placeholder. A contested one the DEFAULT skin fills never
2702
- // reaches here: it is refused above (issue #567), because the editor
2703
- // holds no such shape in either spelling.
2704
- const composed = composeSkinAttachmentName(skinName, placeholder, shared?.has(placeholder) === true);
2705
- 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);
2706
2702
  }
2707
2703
  tableFor(skinName)[rigSlot.name] = perSlot;
2708
2704
  }
@@ -3497,49 +3493,63 @@ function rotationOf(spec: RigBone, ctx: BoneContext): number | null {
3497
3493
  }
3498
3494
 
3499
3495
  // ---------------------------------------------------------------------------
3500
- // attachment names, where one placeholder holds several attachments
3496
+ // attachment names, and the one placeholder shape the editor cannot hold
3501
3497
  // ---------------------------------------------------------------------------
3502
3498
 
3503
3499
  /**
3504
- * What separates a skin's name from a placeholder's inside an attachment name.
3505
- *
3506
- * ⚠️ A separator is the one part of this that could collide with a name somebody
3507
- * wrote, so it is measured rather than picked: across the **160** distinct
3508
- * placeholder names and **159** distinct atlas region names in `examples/` and
3509
- * `gallery/` — every editor-authored name this repository has — `/` occurs in
3510
- * **0**, while `-` occurs in 85 / 86 and `_` in 37 / 37. It is also the character
3511
- * the format already has a structure for, since an attachment's name doubles as
3512
- * its texture path and a path is what `/` separates.
3513
- *
3514
- * 🔒 And the choice is not load-bearing anyway, which is the point of stating it
3515
- * this way: `contestedPlaceholders` refuses the build if the name it composes is
3516
- * one some other placeholder in the same slot already answers to. A separator
3517
- * nobody uses makes that refusal rare; the refusal is what makes it safe.
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.
3518
3549
  */
3519
- const SKIN_ATTACHMENT_SEPARATOR = '/';
3520
-
3521
- function skinAttachmentName(skinName: string, placeholder: string): string {
3522
- return `${skinName}${SKIN_ATTACHMENT_SEPARATOR}${placeholder}`;
3523
- }
3524
-
3525
- /**
3526
- * The `name` one skin's entry for a placeholder is emitted with, or `null` for
3527
- * the entries that carry no `name` field at all.
3528
- *
3529
- * Stated once, and called by both the emit and `contestedPlaceholders`'
3530
- * collision walk, because two readings of one rule is how issue #567 happened.
3531
- * By the time either caller runs, a contested placeholder the **default** skin
3532
- * fills has already been refused — see `refuseDefaultSkinContest` — so every
3533
- * entry this composes for is a named skin's.
3534
- *
3535
- * 🔒 A third caller reads it from outside: `ingest` compares the name a source
3536
- * states against the one this returns, and reports the rename where the two
3537
- * differ (issue #746). It calls this rather than `skinAttachmentName` so that
3538
- * WHETHER a name is composed is read off the same line as WHAT it is — the
3539
- * separator and the contest test are each stated once, here.
3540
- */
3541
- export function composeSkinAttachmentName(skinName: string, placeholder: string, contested: boolean): string | null {
3542
- 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);
3543
3553
  }
3544
3554
 
3545
3555
  /**
@@ -3590,12 +3600,13 @@ export function composeSkinAttachmentName(skinName: string, placeholder: string,
3590
3600
  * other. There is no third spelling to find, so this is a `CompileError` and not
3591
3601
  * a scheme, in the shape issue #543 used: refuse by name and say what to do.
3592
3602
  *
3593
- * ⚠️ What this does NOT touch, and the trips measured that half too: a
3594
- * placeholder that two or more NAMED skins fill keeps #552's composition
3595
- * exactly. Trip 8's second rig — two named skins filling `patch`, the default
3596
- * skin holding `block` only — imported, exported and measured **0.0000 mean
3597
- * MAE**, names and paths intact. The remedy this refusal states is that rig:
3598
- * 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.
3599
3610
  */
3600
3611
  function refuseDefaultSkinContest(slotName: string, placeholder: string, skins: readonly string[]): never {
3601
3612
  const named = skins.filter((skin) => skin !== DEFAULT_SKIN);
@@ -3603,78 +3614,28 @@ function refuseDefaultSkinContest(slotName: string, placeholder: string, skins:
3603
3614
  `slot "${slotName}": placeholder "${placeholder}" is filled by the "${DEFAULT_SKIN}" skin AND by ` +
3604
3615
  `${named.length === 1 ? 'skin' : 'skins'} ${named.map((skin) => `"${skin}"`).join(', ')}, and the Spine ` +
3605
3616
  'editor has no way to hold that. Measured on 4.3.26 in both spellings: give the default skin\'s attachment a ' +
3606
- `name of its own ("${DEFAULT_SKIN}${SKIN_ATTACHMENT_SEPARATOR}${placeholder}") and the editor re-keys it by ` +
3607
- `that name on export, so the slot's setup attachment "${placeholder}" resolves in no default-skin key and the ` +
3608
- '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 ' +
3609
3620
  `"Multiple attachments have the same name: ${placeholder} ${placeholder}", because a default-skin attachment ` +
3610
3621
  "hangs on the slot beside the named skins' placeholder of that name. Move the default skin's entry for this " +
3611
3622
  `slot into a named skin — call it "base" — so every skin filling "${placeholder}" is a named one. Two or more ` +
3612
- 'named skins sharing a placeholder is the shape the editor does hold, and rigc composes their names for them ' +
3613
- '(#541, #552).',
3623
+ 'named skins sharing a placeholder is the shape the editor exports itself, and it needs no `name` (#796).',
3614
3624
  );
3615
3625
  }
3616
3626
 
3617
3627
  /**
3618
- * Which `(slot, placeholder)` pairs more than one skin fills — and, on the way,
3619
- * the refusal that keeps the composed names from colliding with authored ones.
3620
- *
3621
- * ## The defect this exists for
3622
- *
3623
- * Two skins putting different art under one placeholder is what a skin IS, and
3624
- * until issue #541 rigc emitted both entries with no `name`, which makes the
3625
- * placeholder the name of both (`SkeletonJson.ts:526`). spine-core is happy —
3626
- * its skin table is keyed by placeholder, so the two never meet. The Spine
3627
- * editor refuses the whole import, and says exactly why:
3628
- *
3629
- * ERROR: Unable to import skeleton.
3630
- * [error] Error reading skeleton: skins
3631
- * Cause: [error] Error reading attachment: patch (MOw)
3632
- * Cause: [error] Multiple attachments have the same name: patch patch
3633
- *
3634
- * Bisected on the emitted file: four skins REFUSED, deform timelines removed
3635
- * REFUSED, `default` + one skin REFUSED, `default` alone IMPORTS, the second skin
3636
- * given a distinct placeholder IMPORTS, and the same placeholder with **each
3637
- * entry given its own `name`** IMPORTS — all four skins. So it is neither the
3638
- * skin count nor the timelines; it is one name over several attachments.
3639
- *
3640
- * ## Only the contested pairs are named, and the default skin may not contest
3641
- *
3642
- * A placeholder one skin fills keeps the emitted shape it has always had: no
3643
- * `name`, no `path` it did not already carry. Every rig in this tree declares
3644
- * exactly one skin, so **no emitted byte in the tree moves** — and a
3645
- * multi-skin rig whose skins use distinct placeholders does not move either,
3646
- * because nothing there is ambiguous to begin with.
3647
- *
3648
- * ⚠️ A contested placeholder the **default** skin fills is refused before any
3649
- * of this runs — `refuseDefaultSkinContest`, issue #567 — because two editor
3650
- * round trips showed the editor holds no such shape in either spelling. So
3651
- * every entry the walk below composes for belongs to a named skin, and the
3652
- * emitted name comes off `composeSkinAttachmentName`, which this function calls
3653
- * rather than restates: the emit and the refusal disagreeing about one name is
3654
- * the defect both of them exist to prevent.
3655
- *
3656
- * ⚠️ The scope of the editor's uniqueness rule is **not** skeleton-wide, and the
3657
- * corpus proves it rather than a hypothesis doing so: `spineboy-pro.json`, which
3658
- * the editor wrote, gives the name `head` to a region in slot `head` and to a
3659
- * bounding box in slot `head-bb`, and names one `hoverglow-small` across eight
3660
- * slots. What #541 refused was one slot. Composing from the skin makes the names
3661
- * unique within the slot, which satisfies that scope and every narrower one;
3662
- * nothing here claims to know which of them the editor actually applies, and an
3663
- * assertion that policed the emitted artifact would have to.
3664
- *
3665
- * ## Why a composed name is not the compiler inventing a value
3666
- *
3667
- * rigc has always decided this attachment's name — it decided it was the
3668
- * placeholder, silently, and that decision is the defect. What changes is the
3669
- * derivation, not who makes it, and the new one is a function of two names the
3670
- * spec wrote. Nothing is read off the art, and `path` — the field that says
3671
- * which texture to draw — stays exactly what the spec stated or what the
3672
- * 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.
3673
3637
  */
3674
- function contestedPlaceholders(
3675
- skinNames: readonly string[],
3676
- skinParts: Map<string, RigSkinParts>,
3677
- ): Map<string, Set<string>> {
3638
+ function refuseDefaultSkinContests(skinNames: readonly string[], skinParts: Map<string, RigSkinParts>): void {
3678
3639
  /** slot -> placeholder -> the skins that fill it, in declaration order. */
3679
3640
  const fillers = new Map<string, Map<string, string[]>>();
3680
3641
  for (const skinName of skinNames) {
@@ -3686,82 +3647,11 @@ function contestedPlaceholders(
3686
3647
  fillers.set(slotName, perSlot);
3687
3648
  }
3688
3649
  }
3689
- const contested = new Map<string, Set<string>>();
3690
- const collisions: string[] = [];
3691
3650
  for (const [slotName, perSlot] of fillers) {
3692
- const shared = new Set([...perSlot].filter(([, skins]) => skins.length > 1).map(([placeholder]) => placeholder));
3693
- // 🚨 Before anything is composed: a contested placeholder the DEFAULT skin
3694
- // fills has no representation in the editor at all, in either spelling
3695
- // (issue #567, round trips 7 and 8). It is refused here rather than emitted,
3696
- // and the refusal comes first because renaming cannot repair it — the
3697
- // remedy is a different rig, not a different string.
3698
- for (const placeholder of shared) {
3699
- const skins = perSlot.get(placeholder)!;
3700
- if (skins.includes(DEFAULT_SKIN)) refuseDefaultSkinContest(slotName, placeholder, skins);
3701
- }
3702
- if (shared.size) contested.set(slotName, shared);
3703
- /** Emitted attachment name -> the first entry that claimed it. */
3704
- const claimed = new Map<string, string>();
3705
3651
  for (const [placeholder, skins] of perSlot) {
3706
- for (const skinName of skins) {
3707
- // The emitted name, read off the one function that decides it — so the
3708
- // refusal and the emit cannot drift into two readings. An UNCONTESTED
3709
- // entry is claimed under its bare placeholder, the default skin's
3710
- // included: a named skin whose composed name equals it is a collision,
3711
- // and one this walk sees for the same reason it sees every other.
3712
- const name = composeSkinAttachmentName(skinName, placeholder, shared.has(placeholder)) ?? placeholder;
3713
- const site = `skin "${skinName}" placeholder "${placeholder}"`;
3714
- const taken = claimed.get(name);
3715
- if (taken === undefined) claimed.set(name, site);
3716
- else collisions.push(`slot "${slotName}": ${taken} and ${site} would both be named "${name}"`);
3717
- }
3652
+ if (skins.length > 1 && skins.includes(DEFAULT_SKIN)) refuseDefaultSkinContest(slotName, placeholder, skins);
3718
3653
  }
3719
3654
  }
3720
- if (collisions.length) {
3721
- throw new CompileError(
3722
- `${collisions.length} attachment name collision(s): a placeholder that more than one skin fills is emitted ` +
3723
- `with the name "<skin>${SKIN_ATTACHMENT_SEPARATOR}<placeholder>", because the Spine editor refuses an import ` +
3724
- 'in which one slot holds two attachments of one name (#541) — and here that composed name is one another ' +
3725
- 'entry in the same slot already answers to. Rename the placeholder or the skin so the two differ. ' +
3726
- `${collisions.join('. ')}`,
3727
- );
3728
- }
3729
- return contested;
3730
- }
3731
-
3732
- /**
3733
- * Give one attachment its own `name`, and pin the texture `path` that name would
3734
- * otherwise have taken with it.
3735
- *
3736
- * 🚨 The second half is the whole hazard. `readAttachment` reads
3737
- * `const name = getValue(map, "name", placeholder)` and then
3738
- * `const path = getValue(map, "path", name)` (`SkeletonJson.ts:526-529`, and
3739
- * again at `:559` for a mesh) — so `path` defaults to the NAME, not to the
3740
- * placeholder. Writing a name and leaving `path` alone silently repoints the
3741
- * attachment's texture lookup at a region no atlas has. Restating `path` at what
3742
- * the attachment already resolved to makes the name change invisible to
3743
- * everything but the editor's own uniqueness rule, which is the only thing it is
3744
- * for.
3745
- *
3746
- * ⚠️ `region` and `mesh` are exactly the two types that read `path`; the polygon
3747
- * types (`boundingbox`, `clipping`, `path`) have no texture and get the name
3748
- * alone. The list is the parser's own two `getValue(map, "path", …)` sites
3749
- * rather than a judgement about which attachments "have art".
3750
- */
3751
- function nameSkinAttachment(att: SpineAttachment, name: string, placeholder: string): SpineAttachment {
3752
- const kind = (att as { type?: string }).type ?? 'region';
3753
- // Key order is the parser's reading order — `name`, then `path`, then the rest
3754
- // as the builder wrote it — for the same reason every other emitted object
3755
- // follows it: the file is read by people and diffed against references.
3756
- //
3757
- // ⚠️ The three kinds here are the three the parser gives a texture `path` to,
3758
- // and `linkedmesh` is one of them (`SkeletonJson.ts:541`, `:570` — the mesh
3759
- // branch is shared). Leaving it out would write a `name` and no `path`, and
3760
- // `path` defaults to `name`, so a contested link would resolve the region
3761
- // "<skin>/<placeholder>", which no atlas holds.
3762
- if (kind !== 'region' && kind !== 'mesh' && kind !== 'linkedmesh') return { name, ...att };
3763
- const { path, ...rest } = att as SpineRegionAttachment | SpineMeshAttachment | SpineLinkedMeshAttachment;
3764
- return { name, path: path ?? placeholder, ...rest } as SpineAttachment;
3765
3655
  }
3766
3656
 
3767
3657
  // ---------------------------------------------------------------------------
@@ -3898,6 +3788,20 @@ function buildRigAttachment(
3898
3788
  );
3899
3789
  }
3900
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
+ }
3901
3805
  // A linked mesh in the format's OTHER spelling. `type: "mesh"` and `type:
3902
3806
  // "linkedmesh"` share one branch and the `source` key is what decides between
3903
3807
  // them (`:568-569`, `:582`; SPEC_COVERAGE part 1-6) — so a mesh carrying
@@ -4338,11 +4242,15 @@ function atlasedImage(image: string, where: string, ctx: AttachmentContext): Com
4338
4242
  * through one line of parser, and it would have had to contradict §3.4 rather
4339
4243
  * than satisfy it.
4340
4244
  */
4341
- 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 {
4342
4246
  if (att.path !== undefined) return att.path;
4343
4247
  if (att.image === undefined) return undefined;
4344
4248
  const region = basename(att.image, '.png');
4345
- 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;
4346
4254
  }
4347
4255
 
4348
4256
  /**
@@ -4364,7 +4272,7 @@ function attachmentPath(att: { path?: string; image?: string }, placeholder: str
4364
4272
  * keys where `buildRigLinkedMesh` does — because the analogy to a mesh is
4365
4273
  * obvious and it is not a measurement.
4366
4274
  */
4367
- function meshTextureKeys(att: { path?: string; image?: string; color?: string }, placeholder: string): Pick<SpineMeshAttachment, 'path' | 'color'> {
4275
+ function meshTextureKeys(att: { name?: string; path?: string; image?: string; color?: string }, placeholder: string): Pick<SpineMeshAttachment, 'path' | 'color'> {
4368
4276
  const out: Pick<SpineMeshAttachment, 'path' | 'color'> = {};
4369
4277
  const path = attachmentPath(att, placeholder);
4370
4278
  if (path !== undefined) out.path = path;
@@ -4408,8 +4316,22 @@ function emitSequence(seq: RigSequence): SpineSequence {
4408
4316
  * measures the same: that is the one number the frames state. Frames of
4409
4317
  * different sizes state several, and picking one — the first, the setup frame,
4410
4318
  * the largest — would be the compiler choosing a value the spec did not.
4411
- * Under `--atlas-in` a stated size that disagrees with a frame is refused as it
4412
- * 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.
4413
4335
  */
4414
4336
  function sequenceFrameSize(
4415
4337
  frames: readonly CompiledImage[],
@@ -4420,14 +4342,6 @@ function sequenceFrameSize(
4420
4342
  for (const field of ['width', 'height'] as const) {
4421
4343
  const sizes = [...new Set(frames.map((img) => img[field]))];
4422
4344
  if (stated[field] !== undefined) {
4423
- const packed = frames.find((img) => img.atlas !== undefined && img[field] !== stated[field]);
4424
- if (packed !== undefined) {
4425
- throw new CompileError(
4426
- `${where}: the spec says ${field} ${stated[field]} and sequence frame "${packed.region}" of the imported ` +
4427
- `atlas is ${packed[field]}; a packed frame's rectangle is fixed, so the two would produce a quad the ` +
4428
- 'pack cannot fill',
4429
- );
4430
- }
4431
4345
  out[field] = stated[field];
4432
4346
  } else if (sizes.length === 1) {
4433
4347
  out[field] = sizes[0];
@@ -4445,7 +4359,8 @@ function sequenceFrameSize(
4445
4359
  /** Every frame of `att`'s sequence, already atlased by the gather pass. */
4446
4360
  function sequenceFrames(att: { path?: string; sequence?: RigSequence }, placeholder: string, where: string, ctx: AttachmentContext): CompiledImage[] {
4447
4361
  const seq = att.sequence!;
4448
- 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;
4449
4364
  const frames: CompiledImage[] = [];
4450
4365
  for (let i = 0; i < seq.count; i++) {
4451
4366
  // By the frame's FULL region name — `atlasedImage` takes a basename, and a
package/src/diff.ts CHANGED
@@ -683,6 +683,12 @@ interface AttachmentFact {
683
683
  edgesPresent: boolean | null;
684
684
  /** `<width>x<height>` as stated, or `unstated` — see `attachments.region_size`. */
685
685
  size: string;
686
+ /**
687
+ * The name the runtime gives the attachment: its stated `name`, else its
688
+ * placeholder key (`getValue(map, "name", placeholder)`, `SkeletonJson.js:526`)
689
+ * — see `attachments.runtime_name`.
690
+ */
691
+ runtimeName: string;
686
692
  }
687
693
 
688
694
  function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, AttachmentFact> } {
@@ -718,6 +724,7 @@ function attachmentFacts(root: Json): { skins: Set<string>; byKey: Map<string, A
718
724
  // "has edges" means.
719
725
  edgesPresent: type === 'mesh' ? 'edges' in att : null,
720
726
  size: num(att.width) !== null && num(att.height) !== null ? `${num(att.width)}x${num(att.height)}` : 'unstated',
727
+ runtimeName: str(att.name) ?? attName,
721
728
  });
722
729
  }
723
730
  }
@@ -820,9 +827,61 @@ function diffAttachments(c: Json, r: Json): DiffSection {
820
827
  bm,
821
828
  (x, y) => x.edgesPresent === y.edgesPresent,
822
829
  ),
830
+ // ── issue #796 ───────────────────────────────────────────────────────────
831
+ //
832
+ // The name the RUNTIME gives each attachment — `name` if stated, else the
833
+ // placeholder key — agreed per skin/slot/placeholder. Every measure above is
834
+ // keyed by the placeholder, so a rebuild that renamed every attachment while
835
+ // keeping every key read 1.000 across this whole section: that is what a
836
+ // rebuild did on two production rigs (a stated name respelled as `path`, and
837
+ // `<skin>/<placeholder>` composed over a name the file never stated), and it
838
+ // took a pose oracle comparing `slot.attachment.name` to see it.
839
+ //
840
+ // 🚫 Reported rather than in the mean, by the same test as `mesh_edges`: a
841
+ // name draws no pixel, so no reading of the frames could decide it. It is
842
+ // still a value a consumer reads — `slot.attachment.name` — which is why it
843
+ // is measured at all.
844
+ //
845
+ // ⚠️ Over the keys BOTH sides hold, not over the larger roster, and that is
846
+ // the one departure from `agreement` here. A key one side lacks is already
847
+ // `attachments.names` and `attachments.count`; scored again here it would
848
+ // move this measure on every rename of a key and every dropped attachment,
849
+ // and "the same key answers to another name" — the only thing nothing else
850
+ // reads — would be one term among many. The denominator this reports is the
851
+ // number of keys compared, so a report over few shared keys says so.
852
+ runtimeNames(a.byKey, b.byKey),
823
853
  ]);
824
854
  }
825
855
 
856
+ /**
857
+ * `attachments.runtime_name`: of the skin/slot/placeholder keys both sides
858
+ * hold, how many answer to the same runtime name — see the note at its call.
859
+ */
860
+ function runtimeNames(a: Map<string, AttachmentFact>, b: Map<string, AttachmentFact>): DiffMeasure {
861
+ let shared = 0;
862
+ let agree = 0;
863
+ const differ: string[] = [];
864
+ for (const [key, fact] of a) {
865
+ const other = b.get(key);
866
+ if (other === undefined) continue;
867
+ shared++;
868
+ if (fact.runtimeName === other.runtimeName) agree++;
869
+ else if (differ.length < 3) differ.push(`${key} "${fact.runtimeName}" vs "${other.runtimeName}"`);
870
+ }
871
+ const missed = shared - agree;
872
+ return measure(
873
+ 'attachments.runtime_name',
874
+ 'each attachment both sides hold answers to the same name at runtime (`name` if stated, else its placeholder)',
875
+ agree,
876
+ shared,
877
+ shared === 0
878
+ ? 'no skin/slot/placeholder key is on both sides, so no name was compared'
879
+ : missed === 0
880
+ ? undefined
881
+ : `${missed} renamed: ${differ.join('; ')}${missed > differ.length ? `; …and ${missed - differ.length} more` : ''}`,
882
+ );
883
+ }
884
+
826
885
  interface ConstraintFact {
827
886
  type: string;
828
887
  /** Every bone or slot the constraint names, sorted — its wiring. */