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 +7 -1
- package/docs/AUTHORING.md +141 -21
- package/docs/SPEC_COVERAGE.md +9 -0
- package/package.json +2 -1
- package/src/compile.ts +462 -42
- package/src/types.ts +48 -13
- package/tools/editor_roundtrip.ts +99 -12
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(
|
|
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
|
|
4976
|
-
Read off its export of a rigc build (Spine 4.3.26,
|
|
4977
|
-
`animations` object, a skin's 24 `attachments` slot keys and
|
|
4978
|
-
and 2 bone-timeline keys all came back sorted, while the 30
|
|
4979
|
-
and 3 `constraints` — arrays — came back in the build's own
|
|
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
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
|
|
5018
|
-
|
|
5019
|
-
`skins[
|
|
5020
|
-
|
|
5021
|
-
|
|
5022
|
-
|
|
5023
|
-
|
|
5024
|
-
|
|
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
|
|
package/docs/SPEC_COVERAGE.md
CHANGED
|
@@ -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.
|
|
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
|
-
*
|
|
425
|
-
*
|
|
426
|
-
*
|
|
427
|
-
*
|
|
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(
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1494
|
-
|
|
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'
|
|
1497
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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 :
|
|
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 :
|
|
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
|
|
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
|
|
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`
|
|
888
|
-
*
|
|
889
|
-
* `
|
|
890
|
-
*
|
|
891
|
-
*
|
|
892
|
-
*
|
|
893
|
-
*
|
|
894
|
-
*
|
|
895
|
-
*
|
|
896
|
-
*
|
|
897
|
-
*
|
|
898
|
-
*
|
|
899
|
-
*
|
|
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))
|
|
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
|
-
|
|
479
|
-
|
|
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
|
-
|
|
637
|
+
keepLog();
|
|
551
638
|
emit('');
|
|
552
|
-
emit(`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
|
}
|