spine-rigc 0.21.0 → 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/cli.ts CHANGED
@@ -2411,8 +2411,14 @@ function cmdExplain(flags: Record<string, string>): void {
2411
2411
  }
2412
2412
 
2413
2413
  console.log('\nslots (array order IS the draw order)');
2414
+ // The DEFAULT skin's placeholders, resolved by name. It was `skins[0]` until
2415
+ // issue #541, which is the same thing only because rigc pins `default` at
2416
+ // index 0 — a property of the emitter that this report should not be quietly
2417
+ // relying on. Reading it by name means a change to the skins ORDER cannot turn
2418
+ // this line into a report about some other skin.
2419
+ const defaultSkin = result.skeleton.skins.find((skin) => skin.name === 'default');
2414
2420
  for (const s of result.skeleton.slots) {
2415
- const atts = Object.keys(result.skeleton.skins[0].attachments[s.name] ?? {});
2421
+ const atts = Object.keys(defaultSkin?.attachments[s.name] ?? {});
2416
2422
  console.log(
2417
2423
  ` ${s.name.padEnd(12)} bone=${s.bone.padEnd(12)} setup=${(s.attachment ?? 'null').padEnd(22)} color=${s.color ?? 'ffffffff'} attachments=[${atts.join(', ')}]`,
2418
2424
  );
package/docs/AUTHORING.md CHANGED
@@ -540,6 +540,15 @@ in the skeleton and the size in the atlas cannot drift apart. The **region name
540
540
  PNG's basename**; when your placeholder name differs from it, rigc writes a `path`
541
541
  so the attachment still joins to the region.
542
542
 
543
+ **Every** attachment that names an `image` is measured, whichever skin it sits in.
544
+ The atlas holds **one region per file**, so two skins filling one placeholder from
545
+ two files put two regions in it and each attachment draws its own (§3.4.2), while
546
+ two attachments naming the same file share the one region that file made. What
547
+ cannot be reconciled is two *different* files whose basenames collide — only one of
548
+ them can be region `patch` — so rigc refuses the build and names both paths rather
549
+ than letting one of them silently draw the other's pixels
550
+ ([#555](https://github.com/firejune/rigc/issues/555)).
551
+
543
552
  **R6 — A key carries `ease` or `curve`, never both.** A named easing says "this
544
553
  shape, wherever it is used" and is the recommended path. `curve` is the escape
545
554
  hatch: the absolute `(time, value)` control points, verbatim, for when every key
@@ -612,6 +621,33 @@ only repair was *rename* — the one repair a transcription cannot take. Emittin
612
621
  member of the family instead moves no byte on any set the old rule accepted; it
613
622
  just stops refusing the ones it did.
614
623
 
624
+ **R11 — The `skins` array is written with `default` first and the rest in the
625
+ editor's order, and skin names that have no one order are refused.** The same rule
626
+ as R10, in the collection that was believed exempt from it. A skin is a **name** in
627
+ the JSON half of the format and an **ordinal** in the binary half —
628
+ `skins[readInt()]` for an attachment timeline, `skins[skinIndex]` for a linked mesh
629
+ — so an editor that writes the array in another order repoints every such
630
+ reference, silently, in a file that still parses. Measured: a rig built
631
+ `default, zulu, mike, alpha` exported `default, alpha, mike, zulu`
632
+ ([#541](https://github.com/firejune/rigc/issues/541)).
633
+
634
+ ⚠️ **The refusal here is wider than R10's, and deliberately.** R10 can be narrow
635
+ because #539 measured two animation-name pairs and thereby *refuted* a codepoint
636
+ sort. The skins measurement refutes nothing — `alpha, mike, zulu` is the answer
637
+ codepoint, folding and natural order all give — so the editor's skin comparator is
638
+ **not established**, and rigc refuses any pair those candidates could disagree
639
+ about. In practice that is R10's four rows plus two more: a pair a case fold
640
+ reverses (`Zulu` against `mike`) and two digit runs of unequal width (`mike10`
641
+ against `mike2`). Both of those *build* as animation names and are refused as skin
642
+ names, and the two refusals say which is which.
643
+
644
+ **R12 — A placeholder that more than one skin fills gets a per-skin attachment
645
+ `name`.** rigc writes `"name": "<skin>/<placeholder>"` on each of those entries,
646
+ and restates `path` beside it so the texture still resolves where it did. You do
647
+ not author this and there is nothing to do about it — but it is visible in the
648
+ emitted file, so §3.4.2 says what it is and why. A placeholder only one skin fills
649
+ is emitted exactly as before.
650
+
615
651
  ---
616
652
 
617
653
  ## 3. The rig spec, field by field
@@ -709,7 +745,7 @@ the default `type`:
709
745
  | `type` | `"region"`, or omit |
710
746
  | `image` | **rigc extension.** A PNG relative to the rig's `images` directory; rigc measures it (R5) |
711
747
  | `width`, `height` | required by the format — give them, or give an `image` |
712
- | `path` | the atlas region to resolve; defaults to the attachment's own name. rigc sets it for you when the PNG basename differs from the placeholder |
748
+ | `path` | the atlas region to resolve; defaults to the attachment's own name. rigc sets it for you when the PNG basename differs from the placeholder, and whenever it composes a `name` because more than one skin fills this placeholder (§3.4.2) |
713
749
  | `x`, `y` | offset from the bone, in the bone's local space |
714
750
  | `rotation` | degrees; cancels a rotated bone for a plate authored screen-upright |
715
751
  | `scaleX`, `scaleY`, `color` | as Spine |
@@ -1372,6 +1408,70 @@ beside them is refused rather than ignored (an ignored slot is an attachment tha
1372
1408
  vanishes), and a rig with a *slot* of one of those names is refused too, because
1373
1409
  there the two forms are genuinely ambiguous. Rename the slot.
1374
1410
 
1411
+ #### 3.4.2 Two skins, one placeholder — the `name` rigc writes for you
1412
+
1413
+ Two skins putting different art under one placeholder is what a skin is *for*, and
1414
+ it is the one shape rigc emitted wrongly until
1415
+ [#541](https://github.com/firejune/rigc/issues/541). Without a `name` field an
1416
+ attachment's name **is** its placeholder (`SkeletonJson.ts:526`), so four skins
1417
+ filling `patch` are four different attachments all called `patch`. spine-core never
1418
+ notices — its skin table is keyed by placeholder, so the two never meet — and the
1419
+ whole gate is green. The Spine editor refuses the import outright:
1420
+
1421
+ ```
1422
+ ERROR: Unable to import skeleton.
1423
+ [error] Error reading skeleton: skins
1424
+ Cause: [error] Error reading attachment: patch (MOw)
1425
+ Cause: [error] Multiple attachments have the same name: patch patch
1426
+ ```
1427
+
1428
+ ⇒ rigc now writes each of those entries a name of its own, composed from the two
1429
+ names you already gave it:
1430
+
1431
+ ```json
1432
+ "skins": {
1433
+ "default": { "patch": { "patch": { "image": "patch_a.png" } } },
1434
+ "zulu": { "patch": { "patch": { "image": "patch_a.png", "x": 4 } } }
1435
+ }
1436
+ ```
1437
+
1438
+ emits
1439
+
1440
+ ```json
1441
+ { "name": "default/patch", "path": "patch", "width": 64, "height": 64 }
1442
+ { "name": "zulu/patch", "path": "patch", "width": 64, "height": 64, "x": 4 }
1443
+ ```
1444
+
1445
+ Three things to know about it and nothing to author:
1446
+
1447
+ - **`path` is restated, and it has to be.** `path` defaults to the attachment's
1448
+ **name**, not to its placeholder, so an entry given a name and no path would
1449
+ resolve its texture at `zulu/patch` and find no such region. `A00_ROUNDTRIP_PARSE`
1450
+ says so in the parser's own words if it is ever dropped.
1451
+ - **Only contested placeholders are touched.** One skin filling a placeholder, or
1452
+ two skins filling a slot under *different* placeholders, emit exactly what they
1453
+ always did — every rig in this repository is byte-identical across the change.
1454
+ - **A composed name that collides is a compile error, not a surprise.** If some
1455
+ other placeholder in the same slot is literally called `zulu/patch`, rigc refuses
1456
+ and names both sites rather than emitting two attachments with one name again.
1457
+ (`/` is the separator because it appears in **0** of the 160 placeholder names and
1458
+ 159 atlas region names in `examples/` and `gallery/`, where `-` appears in 85 and
1459
+ `_` in 37.)
1460
+ - **Each skin's art is measured and atlased on its own.** The example above points
1461
+ both skins at one PNG, so there is one region; point them at two and there are
1462
+ two, each attachment's `path` resolving to the file that attachment named and its
1463
+ `width`/`height` measured off that file. Until
1464
+ [#555](https://github.com/firejune/rigc/issues/555) only the first skin's PNG was
1465
+ ever opened, and the second skin's art reached neither the atlas nor the
1466
+ measurement — so name the two files **distinctly**, because the region name is
1467
+ the basename and `a/patch.png` beside `b/patch.png` is refused (R5).
1468
+
1469
+ ⚠️ **The uniqueness scope is the slot, not the skeleton.** `spineboy-pro.json`,
1470
+ which the editor wrote, gives the name `head` to a region in slot `head` and to a
1471
+ bounding box in slot `head-bb`, and reuses `hoverglow-small` across eight slots. So
1472
+ a name shared between slots is normal and rigc leaves it alone; what #541 refused
1473
+ was one slot holding two.
1474
+
1375
1475
  ### 3.5 `constraints` — 4.3's single typed array
1376
1476
 
1377
1477
  Spine 4.3 folds every constraint into one `constraints` array with a `type`
@@ -3365,6 +3465,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
3365
3465
  | `the triangles do not tile the outline: …` / `the triangles' outline is not one closed loop: …` | §3.4 — a doubled triangle, an unused vertex, a pinch or a hole in `triangles` |
3366
3466
  | `image "X.png" is not on disk at …` | fix the name, or point `--images` at the right directory |
3367
3467
  | `duplicate region name "X"` | two PNGs share a basename; one part, one page, one name |
3468
+ | `"b/X.png" and the art already atlased as region "X" are two different files — … Rename one of the PNGs.` | R5 — the region name is the basename, so only one of the two can hold it. Rename a file (not a placeholder: the placeholder is free to repeat) |
3469
+ | `the image "X.png" was never added to the atlas, so there is no region "X" …` | nothing in the spec — every image an attachment names is measured, so this says rigc skipped one. Report it ([#555](https://github.com/firejune/rigc/issues/555)) |
3368
3470
  | `motion spec names archetype "A" but the rig spec at … is called "B"` | make `archetype` equal the rig's `name` |
3369
3471
  | `animation "A" declares duration Ns but its last key is at Ms` | R7 — fix whichever of the two you meant |
3370
3472
  | `animation "A" slot "X" attachment: key at Ns is Ms past the declared duration Ds` | §4.5 — the key is past the end of the animation and nothing will sample it. Move the key onto `duration`, or raise `duration` |
@@ -3426,6 +3528,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
3426
3528
  | `animation "A" keys "X" as a path constraint, but the rig declares it as a "slider"` | §4.12 — a timeline group resolves by name AND type; use the field named after the constraint's own type |
3427
3529
  | `animation "A": "position" is a path constraint timeline, and this track names no constraint` | §4.12 — put the name in `"path"` |
3428
3530
  | `N pair(s) of animation names have no one order: … "turn" / "Turn" (case) — they are one name in two cases, and which of them the editor puts first is not measured; rename one of them so they differ by more than letter case` | **R10** — rename until no pair is left. The kind in brackets says which of the editor comparator's four UNMEASURED choices decides the pair: `case` (a pure case tie), `number` (one number written two ways, or a run of digits against a word) or `separator` (make the first character that differs a letter or a digit). rigc keys `animations` in the editor's own comparator — natural and case-insensitive ([#539](https://github.com/firejune/rigc/issues/539), [#543](https://github.com/firejune/rigc/issues/543)) — so a pair that comparator settles is emitted rather than refused, and only the four choices nobody has measured are a compile error; on those, the editor's re-key repoints every slider whose animation moves index ([#535](https://github.com/firejune/rigc/issues/535)) |
3531
+ | `N pair(s) of skin names have no one order: … "Zulu" / "mike" (case) — folded to one case "Zulu" and "mike" order the other way round, so whether the editor folds SKIN names decides this pair` | **R11** — rename until no pair is left. The same shape as the row above with a **wider** family: #539 measured the editor's comparator for animation names and thereby ruled codepoint out, and nothing has ruled anything out for skin names, so a pair the candidates could disagree about is refused even where the animation rule would emit it. `Zulu`/`mike` and `mike10`/`mike2` build as animation names and are refused as skin names ([#541](https://github.com/firejune/rigc/issues/541)) |
3532
+ | `N attachment name collision(s): a placeholder that more than one skin fills is emitted with the name "<skin>/<placeholder>" … slot "patch": skin "default" placeholder "zulu/patch" and skin "zulu" placeholder "patch" would both be named "zulu/patch"` | **R12** — rename the placeholder or the skin. rigc composes an attachment name for every placeholder more than one skin fills (§3.4.2), and this fires when the composed name is one another entry in the same slot already answers to. Both sites are named; either rename ends it |
3429
3533
 
3430
3534
  ### 5.2 Assertions — the gate
3431
3535
 
@@ -4972,12 +5076,17 @@ low figure as a miss — say in the log that the art did not carry them.
4972
5076
  `default`"* and *"bones are ordered so that the parent always comes before a child
4973
5077
  bone"* — [JSON format](http://esotericsoftware.com/spine-json-format). §3.4.
4974
5078
 
4975
- 🔬 **The editor re-keys every name-keyed OBJECT and leaves every ARRAY alone.**
4976
- Read off its export of a rigc build (Spine 4.3.26, `gallery/look`): the
4977
- `animations` object, a skin's 24 `attachments` slot keys and two animations' 16
4978
- and 2 bone-timeline keys all came back sorted, while the 30 `bones`, 24 `slots`
4979
- and 3 `constraints` — arrays — came back in the build's own order, element for
4980
- element, and each slider kept its place among them.
5079
+ 🔬 **The editor re-keys every name-keyed OBJECT, and leaves `bones`, `slots` and
5080
+ `constraints` alone.** Read off its export of a rigc build (Spine 4.3.26,
5081
+ `gallery/look`): the `animations` object, a skin's 24 `attachments` slot keys and
5082
+ two animations' 16 and 2 bone-timeline keys all came back sorted, while the 30
5083
+ `bones`, 24 `slots` and 3 `constraints` — arrays — came back in the build's own
5084
+ order, element for element, and each slider kept its place among them.
5085
+
5086
+ 🚨 **Not every array: `skins` it re-sorts.** That sentence read *"leaves every
5087
+ ARRAY alone"* for two releases, on those three arrays and nothing else, and
5088
+ `skins` is the one it was wrong about — see the paragraph at the end of this
5089
+ section.
4981
5090
 
4982
5091
  ⚠️ **The order it sorts them into is natural and case-insensitive, not
4983
5092
  codepoint.** This paragraph said codepoint until
@@ -5008,20 +5117,31 @@ firings still resolved **by name** — `0.3 -> mike`, `0.6 -> alpha`, payloads
5008
5117
  intact (#539). So the editor treats `events` and `animations` differently, and
5009
5118
  rigc emits events in the order you declare them.
5010
5119
 
5011
- ⚠️ **Read *"leaves every ARRAY alone"* above as bones, slots and constraints —
5012
- `skins` is the array that round trip was not taken over.** `gallery/look` declares
5013
- one skin, as does every other rig in this repository and all twelve editor exports
5014
- in `examples/`, and a one-element array comes back in order whatever the editor
5015
- does to it — so nothing here is
5016
- evidence about `skins`, and a pull request that once called them *measured
5017
- preserved* was reading a vacuous result ([#544](https://github.com/firejune/rigc/issues/544)).
5018
- It matters because `skins` carries ordinals in the binary half too —
5019
- `skins[readInt()]` for an attachment timeline and a linked mesh's skin index — so
5020
- a re-order there would repoint them the way the `animations` re-key repoints a
5021
- slider. ⛔ And it cannot be measured today: the editor refuses a four-skin rig on
5022
- import without writing a project file or printing a word
5023
- ([#541](https://github.com/firejune/rigc/issues/541)). ⇒ **If you author more than
5024
- one skin, nothing on this page says the editor survives it.**
5120
+ 🚨 **`skins` is re-sorted, with `default` pinned first — measured, and it is the
5121
+ first array measured to move.** A four-skin rig built `default, zulu, mike, alpha`
5122
+ exported `default, alpha, mike, zulu`
5123
+ ([#541](https://github.com/firejune/rigc/issues/541)), and the deform timelines
5124
+ came back keyed `mike, zulu` rather than `zulu, mike` with it. Note `alpha` sorts
5125
+ before `default` under every candidate comparator and still came back second: the
5126
+ default skin is **pinned**, not sorted. It matters for the same reason `animations`
5127
+ does — `skins` carries ordinals in the binary half, `skins[readInt()]` for an
5128
+ attachment timeline and `skins[skinIndex]` for a linked mesh — so this is the
5129
+ `animations` defect (#535) in the collection nobody had checked. ⇒ in rigc: R11.
5130
+
5131
+ ⚠️ **What that measurement does *not* settle is which comparator.** `alpha, mike,
5132
+ zulu` is the order codepoint, case-folding and natural order all produce, so unlike
5133
+ the animations case nothing here refutes anything, and rigc refuses any skin-name
5134
+ pair the candidates could disagree about (R11).
5135
+
5136
+ ✅ **The two readings this replaces.** A pull request once called `skins` *measured
5137
+ preserved*, on the strength of a one-skin rig where a one-element array comes back
5138
+ in order whatever the editor does to it;
5139
+ [#544](https://github.com/firejune/rigc/issues/544) corrected that to *unmeasured*,
5140
+ and added that it could not be measured because the editor refused a four-skin rig
5141
+ on import without printing a word. Both readings were the same generalisation — *an
5142
+ editor does not move arrays* — off the three arrays that were measured, and the
5143
+ "without a word" was rigc's own harness discarding the editor's stderr, which had
5144
+ named the cause all along (§3.4.2).
5025
5145
 
5026
5146
  ### 10.2 Draw order
5027
5147
 
@@ -602,6 +602,15 @@ with the member's own `skin: true` — either half alone is refused, because `Sk
602
602
  | `clipping` | ✅ under `--profile spine` · 🚫 under `spine-html` | `end` (refused when it names no slot), `convex`, `inverse`, `vertexCount`, geometry, `color`. **A33**, and **A11_NO_CLIPPING_ATTACHMENTS** is the renderer-profile refusal |
603
603
  | `sequence` block | ❌ | |
604
604
 
605
+ Across all five types, rigc emits the attachment's own **`name`** in exactly one case: a placeholder
606
+ that more than one skin fills (`compile.ts`'s `nameSkinAttachment`). Part 1-5 above states why it
607
+ matters — `name` defaults to the placeholder and `path` defaults to `name` — so several skins under
608
+ one placeholder are several attachments with one name, which spine-core accepts and the Spine editor
609
+ refuses on import ([#541](https://github.com/firejune/rigc/issues/541)). The composed name is
610
+ `<skin>/<placeholder>`, `path` is restated beside it so the region still resolves, and a placeholder
611
+ one skin fills is emitted with neither. Nothing else in the tree carries a `name`: of the twelve
612
+ editor exports in `examples/`, **0** attachments do, because all twelve declare one skin.
613
+
605
614
  Mesh geometry is generated by exactly three procedural generators (`mesh.ts`): `buildRingMesh`
606
615
  (three concentric rings + hub, outer two pinned), `buildRibbonMesh` (a two-wide strip along a bone
607
616
  chain) and `buildContourMesh` (marching-squares trace of the part's own alpha → Douglas-Peucker →
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -59,6 +59,7 @@
59
59
  "validate": "bun cli.ts validate",
60
60
  "explain": "bun cli.ts explain",
61
61
  "selftest": "bun selftest.ts",
62
+ "smoke": "bun scripts/install_smoke.ts",
62
63
  "typecheck": "bunx tsc --noEmit",
63
64
  "typecheck:runs": "bunx tsc --noEmit -p tsconfig.runs.json",
64
65
  "lint": "bunx eslint .",
package/src/compile.ts CHANGED
@@ -58,6 +58,7 @@ import {
58
58
  type RigMeshBinding,
59
59
  type RigPathAttachment,
60
60
  type RigRegionAttachment,
61
+ type RigSkinParts,
61
62
  type RigSpec,
62
63
  type RigVertexGeometry,
63
64
  type RigDepthMap,
@@ -248,7 +249,7 @@ function editorAnimationOrder<T>(animations: Record<string, T>): Record<string,
248
249
  // names: the refusal walks every pair and hands back the verdict it certified
249
250
  // for each, so "the order rigc emits" and "the order rigc checked" cannot drift
250
251
  // into two readings the way a shared comparator still can.
251
- const verdicts = refuseNamesTheEditorCouldKeyDifferently(names);
252
+ const verdicts = refuseNamesTheEditorCouldKeyDifferently(names, ANIMATION_ORDER);
252
253
  // Nested rather than keyed on a joined string: any character this could join
253
254
  // on is one an animation name is allowed to contain, and two pairs that
254
255
  // collided would silently share one verdict.
@@ -258,6 +259,66 @@ function editorAnimationOrder<T>(animations: Record<string, T>): Record<string,
258
259
  return ordered;
259
260
  }
260
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
+
261
322
  /**
262
323
  * The characters whose relative order every member of the measured family agrees
263
324
  * on: the digits and, once case is folded, the lower-case ASCII letters.
@@ -417,14 +478,112 @@ function measuredOrder(a: string, b: string): number | Ambiguity {
417
478
  return ra.length < rb.length ? -1 : 1;
418
479
  }
419
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
+ };
534
+ }
535
+
420
536
  /** How many pairs a refusal spells out before it starts counting them instead. */
421
537
  const PAIRS_SPELLED_OUT = 8;
422
538
 
423
539
  /**
424
- * Refuse a set of animation names the editor could key in an order rigc did not
425
- * emit, and hand back the verdict for every pair that survived — the check that
426
- * lets `editorAnimationOrder` sort by the measured family without choosing a
427
- * member of it.
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.
548
+ */
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.
428
587
  *
429
588
  * It is deliberately **not** conditional on anything: not on the rig declaring a
430
589
  * slider, and not on the rig declaring the editor as a consumer. A slider is what
@@ -435,8 +594,17 @@ const PAIRS_SPELLED_OUT = 8;
435
594
  * slider, or the declaration, would then be the edit that refuses a rig that built
436
595
  * yesterday. What issue #543 changed is the size of what is claimed, not who it is
437
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.
438
603
  */
439
- function refuseNamesTheEditorCouldKeyDifferently(names: readonly string[]): Map<string, Map<string, number>> {
604
+ function refuseNamesTheEditorCouldKeyDifferently(
605
+ names: readonly string[],
606
+ what: OrderedCollection,
607
+ ): Map<string, Map<string, number>> {
440
608
  const verdicts = new Map<string, Map<string, number>>();
441
609
  const put = (x: string, y: string, v: number): void => {
442
610
  const row = verdicts.get(x) ?? new Map<string, number>();
@@ -447,7 +615,7 @@ function refuseNamesTheEditorCouldKeyDifferently(names: readonly string[]): Map<
447
615
  for (let i = 0; i < names.length; i++) {
448
616
  put(names[i], names[i], 0);
449
617
  for (let j = i + 1; j < names.length; j++) {
450
- const verdict = measuredOrder(names[i], names[j]);
618
+ const verdict = what.order(names[i], names[j]);
451
619
  if (typeof verdict === 'number') {
452
620
  put(names[i], names[j], verdict);
453
621
  put(names[j], names[i], -verdict);
@@ -459,12 +627,7 @@ function refuseNamesTheEditorCouldKeyDifferently(names: readonly string[]): Map<
459
627
  if (!found.length) return verdicts;
460
628
  const spelled = found.slice(0, PAIRS_SPELLED_OUT);
461
629
  throw new CompileError(
462
- `${found.length} pair(s) of animation names have no one order: rigc keys the emitted "animations" object in ` +
463
- "the Spine editor's own comparator, which is natural and case-insensitive (#539) — but four of that " +
464
- 'comparator\'s choices have never been measured (a pure case tie, one number written two ways, a run of ' +
465
- 'digits against a word, and what a separator is worth), and each pair below is decided by one of them. A ' +
466
- "slider's animation is an ORDINAL in the format's binary half, and an editor that keys these differently " +
467
- 'repoints 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}` +
468
631
  `${spelled.join('. ')}` +
469
632
  (found.length > spelled.length ? `. …and ${found.length - spelled.length} more pair(s)` : ''),
470
633
  );
@@ -1286,6 +1449,16 @@ export function compile(opts: CompileOptions): CompileResult {
1286
1449
  const images: CompiledImage[] = [];
1287
1450
  const droppedStates: CompileResult['droppedStates'] = [];
1288
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>();
1289
1462
  /**
1290
1463
  * Every directory the spec names a part PNG in — `skeletonImagesPath` reads it.
1291
1464
  * Recorded from the spec's own path whether or not the pixels came from there:
@@ -1308,11 +1481,53 @@ export function compile(opts: CompileOptions): CompileResult {
1308
1481
  ? fromLoosePng(relPath, loosePath, region, isBase, outDir)
1309
1482
  : resolveFromAtlas(relPath, region, isBase, outDir, atlasIn);
1310
1483
  seenRegions.add(region);
1484
+ regionSource.set(region, loosePath);
1311
1485
  partDirs.add(dirname(loosePath));
1312
1486
  images.push(img);
1313
1487
  return img;
1314
1488
  };
1315
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
+
1316
1531
  // A manifest may name a part the cut does not carry. A formation can declare
1317
1532
  // more slots than any one cut fills, and a cut that shares a sprite with the
1318
1533
  // scene around it has no plate of its own to point at — the manifest then
@@ -1490,16 +1705,25 @@ export function compile(opts: CompileOptions): CompileResult {
1490
1705
  }
1491
1706
  const names = rigAttachmentNames.get(slotName) ?? [];
1492
1707
  for (const [placeholder, att] of Object.entries(placeholders)) {
1493
- if (names.includes(placeholder)) continue;
1494
- 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);
1495
1714
  const image = (att as RigRegionAttachment).image;
1496
- if (typeof image === 'string' && !seenRegions.has(basename(image, '.png'))) {
1497
- addImage(image, imagesDir, false);
1715
+ if (typeof image === 'string') {
1716
+ addSkinImage(image, `skin "${skinName}" slot "${slotName}" attachment "${placeholder}"`);
1498
1717
  }
1499
1718
  }
1500
1719
  rigAttachmentNames.set(slotName, names);
1501
1720
  }
1502
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);
1503
1727
 
1504
1728
  // -- 2. atlas --------------------------------------------------------------
1505
1729
  //
@@ -1657,9 +1881,10 @@ export function compile(opts: CompileOptions): CompileResult {
1657
1881
  const placeholders = skinParts.get(skinName)!.attachments[rigSlot.name];
1658
1882
  if (!placeholders) continue;
1659
1883
  const perSlot: Record<string, SpineAttachment> = {};
1884
+ const shared = contested.get(rigSlot.name);
1660
1885
  for (const [placeholder, att] of Object.entries(placeholders)) {
1661
1886
  const where = `skin "${skinName}" slot "${rigSlot.name}" attachment "${placeholder}"`;
1662
- perSlot[placeholder] = buildRigAttachment(att, placeholder, where, {
1887
+ const built = buildRigAttachment(att, placeholder, where, {
1663
1888
  images,
1664
1889
  bones,
1665
1890
  transforms,
@@ -1672,6 +1897,12 @@ export function compile(opts: CompileOptions): CompileResult {
1672
1897
  depths: attachmentDepths,
1673
1898
  slotNames: new Set(rig.slots.map((s) => s.name)),
1674
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;
1675
1906
  }
1676
1907
  tableFor(skinName)[rigSlot.name] = perSlot;
1677
1908
  }
@@ -2088,20 +2319,28 @@ export function compile(opts: CompileOptions): CompileResult {
2088
2319
  // `readSkeletonData`'s own order (`:372-443`). Every member list is a
2089
2320
  // conditional spread, so a rig that declares none emits the two-key entry it
2090
2321
  // always did, byte for byte.
2091
- skins: [...skinTables.entries()].map(([name, attachments]) => {
2092
- const parts = skinParts.get(name);
2093
- return {
2094
- name,
2095
- ...(parts?.bones.length ? { bones: parts.bones } : {}),
2096
- ...Object.fromEntries(
2097
- RIG_SKIN_CONSTRAINT_KEYS.filter((key) => parts?.constraints[key].length).map((key) => [
2098
- key,
2099
- parts!.constraints[key],
2100
- ]),
2101
- ),
2102
- attachments,
2103
- };
2104
- }),
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
+ ),
2105
2344
  // Between `skins` and `animations`, which is where the editor writes it. A
2106
2345
  // conditional spread rather than an assignment after the literal, so the key
2107
2346
  // lands in that position instead of at the end.
@@ -2263,6 +2502,154 @@ function rotationOf(spec: RigBone, ctx: BoneContext): number | null {
2263
2502
  return spec.rotation ?? null;
2264
2503
  }
2265
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
+
2266
2653
  // ---------------------------------------------------------------------------
2267
2654
  // rig-declared attachments
2268
2655
  // ---------------------------------------------------------------------------
@@ -2632,13 +3019,50 @@ function buildRigPath(att: RigPathAttachment, where: string, ctx: AttachmentCont
2632
3019
  };
2633
3020
  }
2634
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
+
2635
3059
  function buildRigRegion(
2636
3060
  att: RigRegionAttachment,
2637
3061
  placeholder: string,
2638
3062
  where: string,
2639
3063
  ctx: AttachmentContext,
2640
3064
  ): SpineRegionAttachment {
2641
- 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);
2642
3066
  // ⭐ An IMPORTED region's size is not a default the spec may override. On the
2643
3067
  // loose path `att.width` and the PNG's width are two legitimate numbers — "draw
2644
3068
  // this drawing at this size" is a scale, and the region covers its page either
@@ -2889,7 +3313,7 @@ function buildRigMesh(
2889
3313
  // nor measurable it is a refusal, as it is for a region: 0 is not a size the
2890
3314
  // spec stated, and the editor shows whatever is written here as the image's
2891
3315
  // dimensions (it showed 32x32, its missing-image placeholder, for a 0x0 mesh).
2892
- 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);
2893
3317
  const width = att.width ?? img?.width;
2894
3318
  const height = att.height ?? img?.height;
2895
3319
  if (width === undefined || height === undefined) {
@@ -3396,9 +3820,7 @@ function buildGridAttachment(
3396
3820
  );
3397
3821
  }
3398
3822
 
3399
- const region = basename(att.image, '.png');
3400
- const img = ctx.images.find((im) => im.region === region);
3401
- if (!img) throw new CompileError(`${where}: no compiled image for "${att.image}"`);
3823
+ const img = atlasedImage(att.image, where, ctx);
3402
3824
  const plate = partPlate(img);
3403
3825
 
3404
3826
  let geometry;
@@ -3479,7 +3901,7 @@ function buildGridAttachment(
3479
3901
  height: r6(h),
3480
3902
  };
3481
3903
  if (att.path !== undefined) out.path = att.path;
3482
- else if (region !== placeholder) out.path = region;
3904
+ else if (img.region !== placeholder) out.path = img.region;
3483
3905
  if (att.color !== undefined) out.color = att.color;
3484
3906
  return out;
3485
3907
  }
@@ -3522,9 +3944,7 @@ function buildContourAttachment(
3522
3944
  'there is nothing else here that says which pixels to measure',
3523
3945
  );
3524
3946
  }
3525
- const region = basename(att.image, '.png');
3526
- const img = ctx.images.find((im) => im.region === region);
3527
- if (!img) throw new CompileError(`${where}: no compiled image for "${att.image}"`);
3947
+ const img = atlasedImage(att.image, where, ctx);
3528
3948
  // ⚠️ Nothing here reads the PNG's colour type. `hasAlpha` answers "where does
3529
3949
  // this file keep its alpha", not "is any pixel of it transparent" — a tRNS
3530
3950
  // chunk is real transparency (issue #215) and an all-255 alpha channel is
@@ -3621,7 +4041,7 @@ function buildContourAttachment(
3621
4041
  // basename, so a placeholder named anything else needs `path` written down or
3622
4042
  // the loader resolves nothing.
3623
4043
  if (att.path !== undefined) out.path = att.path;
3624
- else if (region !== placeholder) out.path = region;
4044
+ else if (img.region !== placeholder) out.path = img.region;
3625
4045
  if (att.color !== undefined) out.color = att.color;
3626
4046
  return out;
3627
4047
  }
package/src/types.ts CHANGED
@@ -702,7 +702,24 @@ export interface SpineSlot {
702
702
  blend?: string;
703
703
  }
704
704
 
705
+ /**
706
+ * The attachment's own name, as distinct from the placeholder it is filed under.
707
+ *
708
+ * `readAttachment` reads `const name = getValue(map, "name", placeholder)`
709
+ * (`SkeletonJson.ts:526`), so an absent field means "the placeholder is also the
710
+ * name" — which is what rigc emitted for every attachment until issue #541, and
711
+ * what makes several skins' entries under one placeholder **several attachments
712
+ * with one name**. spine-core does not care; the Spine editor refuses the import
713
+ * outright, naming the section, the attachment and the rule.
714
+ *
715
+ * 🚨 Writing it moves a second field with it. For the two types that carry
716
+ * texture art, `path` defaults to **`name`**, not to the placeholder
717
+ * (`:529`, `:559`), so an attachment given a name and no path resolves its region
718
+ * at the new name and the atlas lookup misses. `nameSkinAttachment` in
719
+ * `compile.ts` is the one place that writes either, and it always writes both.
720
+ */
705
721
  export interface SpineRegionAttachment {
722
+ name?: string;
706
723
  path?: string;
707
724
  /** Required. Omitting these yields NaN with no error. */
708
725
  width: number;
@@ -727,6 +744,8 @@ export interface SpineRegionAttachment {
727
744
  */
728
745
  export interface SpineMeshAttachment {
729
746
  type: 'mesh';
747
+ /** See `SpineRegionAttachment.name` — and it takes `path` with it. */
748
+ name?: string;
730
749
  path?: string;
731
750
  uvs: number[];
732
751
  triangles: number[];
@@ -761,6 +780,8 @@ export interface SpineMeshAttachment {
761
780
  */
762
781
  export interface SpineBoundingBoxAttachment {
763
782
  type: 'boundingbox';
783
+ /** See `SpineRegionAttachment.name`. No `path`: this type reads none. */
784
+ name?: string;
764
785
  vertexCount: number;
765
786
  /** Unweighted x/y pairs, or the weighted run — same encoding as a mesh's. */
766
787
  vertices: number[];
@@ -769,6 +790,8 @@ export interface SpineBoundingBoxAttachment {
769
790
 
770
791
  export interface SpineClippingAttachment {
771
792
  type: 'clipping';
793
+ /** See `SpineRegionAttachment.name`. No `path`: this type reads none. */
794
+ name?: string;
772
795
  /** The last slot the clip applies to. Absent = to the bottom of the order. */
773
796
  end?: string;
774
797
  convex?: boolean;
@@ -790,6 +813,8 @@ export interface SpineClippingAttachment {
790
813
  */
791
814
  export interface SpinePathAttachment {
792
815
  type: 'path';
816
+ /** See `SpineRegionAttachment.name`. No texture `path`: this type reads none. */
817
+ name?: string;
793
818
  closed?: boolean;
794
819
  constantSpeed?: boolean;
795
820
  vertexCount: number;
@@ -884,19 +909,29 @@ export interface SpineSkeletonJson {
884
909
  * #543, with the refusal widened to cover every pair codepoint could order
885
910
  * differently; that refused both rigs above, which are the only two anybody
886
911
  * has measured, and it moved no byte to stop doing so.
887
- * - **`skins` was on the safe side of this list and has no measurement behind
888
- * it.** The arrays the round trip actually returned element for element were
889
- * `bones` (30), `slots` (24) and `constraints` (3). Every rig in this tree
890
- * declares exactly ONE skin, and a one-element array comes back in order
891
- * whatever the editor does with it — #537's pull request called skins
892
- * "measured preserved" on that evidence, and vacuous is not preserved. Nor
893
- * can it be measured today: the editor refuses a four-skin rig on import
894
- * without a word (#541), so there is no export to read. ⇒ `bones` / `slots`
895
- * / `constraints` are the references an editor was measured not to move;
896
- * `skins` is unmeasured, and `SkeletonBinary` addresses it by ordinal too
897
- * (`skins[readInt()]` for an attachment timeline, `skins[skinIndex]` for a
898
- * linked mesh), so it is the collection to measure first if that ever
899
- * becomes possible.
912
+ * - 🚨 **`skins` is NOT an array the editor leaves alone. It is the first one
913
+ * measured moved** (#541). A four-skin rig built `default, zulu, mike,
914
+ * alpha` came back `default, alpha, mike, zulu`: `default` is pinned first
915
+ * and the rest are re-sorted, and the deform timelines came back keyed
916
+ * `mike, zulu` rather than `zulu, mike` with it. `SkeletonBinary` addresses
917
+ * skins by ORDINAL — `skins[readInt()]` for an attachment timeline,
918
+ * `skins[skinIndex]` for a linked mesh — so this is #535 in the collection
919
+ * nobody had checked. rigc emits `default` first and the rest in that
920
+ * order since #541; see `compile.ts`'s `editorSkinOrder`.
921
+ *
922
+ * ⚠️ **The two readings this replaces, kept because the second is the one
923
+ * that cost something.** #537's pull request called `skins` "measured
924
+ * preserved"; #544 corrected that to *unmeasured*, on the grounds that the
925
+ * arrays the round trip actually returned element for element were `bones`
926
+ * (30), `slots` (24) and `constraints` (3), that every rig in this tree
927
+ * declares exactly ONE skin, and that a one-element array comes back in
928
+ * order whatever the editor does with it. Both readings were reached by
929
+ * generalising from the three arrays that *were* measured — "an editor does
930
+ * not move arrays" — and the generalisation is what was false. #544 also
931
+ * said the measurement could not be taken, because the editor refused a
932
+ * four-skin rig on import without a word: that refusal was rigc's own
933
+ * harness discarding the editor's stderr, and the editor had named the
934
+ * cause all along.
900
935
  */
901
936
  events?: Record<string, SpineEvent>;
902
937
  animations: Record<
@@ -260,6 +260,53 @@ interface Ran {
260
260
  timedOut: boolean;
261
261
  }
262
262
 
263
+ /**
264
+ * Everything the editor printed on a step that did not do what it was for.
265
+ *
266
+ * 🚨 This is the defect issue #541 is half about, and it was this tool's. A
267
+ * four-skin rig would not import; the report said
268
+ *
269
+ * ## 1 import (json -> project)
270
+ * exit=1
271
+ * rigc editor_roundtrip: the editor wrote no project file; the import did not happen
272
+ *
273
+ * and the card was filed as *"the editor refuses it without a word"*. The editor
274
+ * had not been silent at all — it named the section, the attachment and the rule:
275
+ *
276
+ * ERROR: Unable to import skeleton.
277
+ * [error] Error reading skeleton: skins
278
+ * Cause: [error] Error reading attachment: patch (MOw)
279
+ * Cause: [error] Multiple attachments have the same name: patch patch
280
+ *
281
+ * `run` captured both streams and the report printed neither. A day of bisecting
282
+ * the emitted file rediscovered what one of those lines says outright, and the
283
+ * repository whose whole doctrine is *convert silence into a named failure* had
284
+ * manufactured the silence.
285
+ *
286
+ * ⭐ The refusal is unchanged and stays unchanged: a step that did not produce
287
+ * its artifact is still a refusal by name, and this adds the reason rather than
288
+ * softening the verdict. What it prints is the editor's own words, quoted and
289
+ * attributed to the stream they came off, and never rewritten — a harness that
290
+ * summarised them would be the same defect with a smaller radius.
291
+ */
292
+ function editorSaid(ran: Ran): string[] {
293
+ const lines: string[] = [];
294
+ for (const [stream, text] of [
295
+ ['stdout', ran.stdout],
296
+ ['stderr', ran.stderr],
297
+ ] as const) {
298
+ const body = text.replace(/\s+$/, '');
299
+ if (body === '') continue;
300
+ lines.push(` the editor's ${stream}:`);
301
+ for (const line of body.split('\n')) lines.push(` | ${line}`);
302
+ }
303
+ // Silence is a finding too, and it has to be stated rather than left to look
304
+ // like a harness that forgot to print. The card above is what an unstated one
305
+ // costs.
306
+ if (lines.length === 0) lines.push(' the editor printed nothing on stdout or stderr');
307
+ return lines;
308
+ }
309
+
263
310
  function run(cmd: string, args: string[], timeoutS: number): Ran {
264
311
  const r = spawnSync(cmd, args, { encoding: 'utf8', timeout: timeoutS * 1000 });
265
312
  return {
@@ -405,24 +452,47 @@ function shapeDiff(before: Shape, after: Shape): string[] {
405
452
  function main(): void {
406
453
  const opts = parseArgs(process.argv.slice(2));
407
454
  const rigc = rigcCommand();
455
+
456
+ const source = join(opts.build, 'skeleton.json');
457
+ const log: string[] = [];
458
+ const emit = (line: string): void => {
459
+ console.log(line);
460
+ log.push(line);
461
+ };
462
+ const logPath = join(opts.out, 'roundtrip.log');
463
+ /**
464
+ * Write what the run has said so far, wherever it stops.
465
+ *
466
+ * ⚠️ `roundtrip.log` used to be written on the last line of `main`, so a run
467
+ * that REFUSED wrote none — and issue #541's card cites the log as the place to
468
+ * read the editor's output, which on a failed import was a file that did not
469
+ * exist. A record kept only for the runs that went well is not a record.
470
+ */
471
+ const keepLog = (): void => {
472
+ // Nothing said, nothing to keep: the refusals that fire before the first
473
+ // `emit` (no build directory) would otherwise leave an empty file and a
474
+ // directory the run never used.
475
+ if (log.length === 0) return;
476
+ try {
477
+ mkdirSync(opts.out, { recursive: true });
478
+ writeFileSync(logPath, `${log.join('\n')}\n`);
479
+ } catch {
480
+ // A log this cannot write is not worth failing a refusal over; the same
481
+ // lines already went to stdout.
482
+ }
483
+ };
408
484
  const fail = (message: string): never => {
485
+ keepLog();
409
486
  console.error(`rigc editor_roundtrip: ${message}`);
410
487
  process.exit(1);
411
488
  };
412
489
 
413
- const source = join(opts.build, 'skeleton.json');
414
490
  if (!existsSync(source)) fail(`no skeleton.json in the build directory ${opts.build}`);
415
491
 
416
492
  rmSync(opts.out, { recursive: true, force: true });
417
493
  mkdirSync(join(opts.out, 'export'), { recursive: true });
418
494
  mkdirSync(join(opts.out, 'export-cand'), { recursive: true });
419
495
 
420
- const log: string[] = [];
421
- const emit = (line: string): void => {
422
- console.log(line);
423
- log.push(line);
424
- };
425
-
426
496
  emit(`## 0 versions`);
427
497
  emit(` rigc ${rigc.how}`);
428
498
  emit(` ${run(rigc.cmd, [...rigc.prefix, '--version'], 60).stdout.trim()}`);
@@ -467,16 +537,33 @@ function main(): void {
467
537
  const project = join(opts.out, `${opts.name}.spine`);
468
538
  const imported = run(opts.editor, [...pin, '-i', source, '-o', project, '-r', opts.name], opts.timeoutS);
469
539
  emit(` exit=${imported.status}${imported.timedOut ? ` TIMED OUT after ${opts.timeoutS}s` : ''}`);
540
+ // A step "went wrong" if it reported failure OR did not leave the artifact
541
+ // it exists to leave. Both are cases where the editor's own words are the
542
+ // next thing anybody needs, and both used to print only `exit=`.
543
+ const importWrong = imported.status !== 0 || imported.timedOut || !existsSync(project);
544
+ if (importWrong) for (const line of editorSaid(imported)) emit(line);
470
545
  if (imported.timedOut) fail(`the editor did not return within ${opts.timeoutS}s on import — that is a hang, not a result`);
471
- if (!existsSync(project)) fail('the editor wrote no project file; the import did not happen');
546
+ if (!existsSync(project)) {
547
+ fail(`the editor wrote no project file; the import did not happen — what it printed is above and in ${logPath}`);
548
+ }
472
549
 
473
550
  emit('');
474
551
  emit('## 2 export (project -> json, default settings)');
475
552
  const exported = run(opts.editor, [...pin, '-i', project, '-o', join(opts.out, 'export'), '-e', 'json'], opts.timeoutS);
476
553
  emit(` exit=${exported.status}${exported.timedOut ? ` TIMED OUT after ${opts.timeoutS}s` : ''}`);
554
+ const written = existsSync(join(opts.out, 'export'))
555
+ ? readdirSync(join(opts.out, 'export')).filter((f) => f.endsWith('.json'))
556
+ : [];
557
+ if (exported.status !== 0 || exported.timedOut || written.length === 0) {
558
+ for (const line of editorSaid(exported)) emit(line);
559
+ }
477
560
  if (exported.timedOut) fail(`the editor did not return within ${opts.timeoutS}s on export — that is a hang, not a result`);
478
- const written = readdirSync(join(opts.out, 'export')).filter((f) => f.endsWith('.json'));
479
- if (written.length === 0) fail('the editor wrote no json; stopping before the re-gate rather than measuring nothing');
561
+ if (written.length === 0) {
562
+ fail(
563
+ 'the editor wrote no json; stopping before the re-gate rather than measuring nothing — what it printed ' +
564
+ `is above and in ${logPath}`,
565
+ );
566
+ }
480
567
  exportedJson = join(opts.out, 'export', written[0]);
481
568
  }
482
569
 
@@ -547,9 +634,9 @@ function main(): void {
547
634
  if (rows.length === 0) emit(' nothing at this resolution: same version, counts, animations and timeline kinds');
548
635
  for (const row of rows) emit(` ${row}`);
549
636
 
550
- writeFileSync(join(opts.out, 'roundtrip.log'), `${log.join('\n')}\n`);
637
+ keepLog();
551
638
  emit('');
552
- emit(`log: ${join(opts.out, 'roundtrip.log')}`);
639
+ emit(`log: ${logPath}`);
553
640
  // The verdict is the gate's and the check's, not this tool's opinion of them.
554
641
  process.exit(gate.status === 0 && check.status === 0 ? 0 : 1);
555
642
  }