spine-rigc 0.22.0 → 0.22.2
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/docs/AUTHORING.md +244 -17
- package/docs/SPEC_COVERAGE.md +14 -8
- package/package.json +1 -1
- package/src/compile.ts +169 -34
- package/src/rig.ts +10 -4
- package/src/types.ts +107 -3
- package/tools/editor_roundtrip.ts +126 -15
package/docs/AUTHORING.md
CHANGED
|
@@ -642,11 +642,17 @@ against `mike2`). Both of those *build* as animation names and are refused as sk
|
|
|
642
642
|
names, and the two refusals say which is which.
|
|
643
643
|
|
|
644
644
|
**R12 — A placeholder that more than one skin fills gets a per-skin attachment
|
|
645
|
-
`name
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
is
|
|
645
|
+
`name`, and the `default` skin may not be one of those skins.** rigc writes
|
|
646
|
+
`"name": "<skin>/<placeholder>"` on each of those entries and restates `path`
|
|
647
|
+
beside it so the texture still resolves where it did; you do not author that and
|
|
648
|
+
there is nothing to do about it, but it is visible in the emitted file, so §3.4.2
|
|
649
|
+
says what it is and why. What you *do* author is where the shared art lives: a
|
|
650
|
+
placeholder the `default` skin shares with a named skin is a **compile error**,
|
|
651
|
+
because the Spine editor has no representation for it in either spelling (#567,
|
|
652
|
+
measured on 4.3.26 — named, the export re-keys it and the default skin draws
|
|
653
|
+
nothing; unnamed, the import is refused). Put the shared entry in a named skin —
|
|
654
|
+
`base` — and every filler is a named skin. A placeholder only one skin fills is
|
|
655
|
+
emitted exactly as before, in the default skin or anywhere else.
|
|
650
656
|
|
|
651
657
|
---
|
|
652
658
|
|
|
@@ -1430,18 +1436,62 @@ names you already gave it:
|
|
|
1430
1436
|
|
|
1431
1437
|
```json
|
|
1432
1438
|
"skins": {
|
|
1433
|
-
"default": { "
|
|
1439
|
+
"default": { "block": { "block": { "image": "block.png" } } },
|
|
1440
|
+
"base": { "patch": { "patch": { "image": "patch_a.png" } } },
|
|
1434
1441
|
"zulu": { "patch": { "patch": { "image": "patch_a.png", "x": 4 } } }
|
|
1435
1442
|
}
|
|
1436
1443
|
```
|
|
1437
1444
|
|
|
1438
|
-
emits
|
|
1445
|
+
emits, for slot `patch`
|
|
1439
1446
|
|
|
1440
1447
|
```json
|
|
1441
|
-
{ "name": "
|
|
1442
|
-
{ "name": "zulu/patch",
|
|
1448
|
+
{ "name": "base/patch", "path": "patch", "width": 64, "height": 64 }
|
|
1449
|
+
{ "name": "zulu/patch", "path": "patch", "width": 64, "height": 64, "x": 4 }
|
|
1443
1450
|
```
|
|
1444
1451
|
|
|
1452
|
+
🚨 **Every skin that shares a placeholder has to be a named one — the default
|
|
1453
|
+
skin may not be among them, and rigc refuses the rig if it is.** That is not a
|
|
1454
|
+
style rule; it is the editor's model, and two round trips through Spine
|
|
1455
|
+
**4.3.26** established it by ruling out both of the only two spellings there are
|
|
1456
|
+
([#567](https://github.com/firejune/rigc/issues/567)):
|
|
1457
|
+
|
|
1458
|
+
- **Give the default skin's entry a name of its own** (`"name": "default/patch"`)
|
|
1459
|
+
and the import succeeds — then the export comes back with that name as the
|
|
1460
|
+
JSON **key** (`"default/patch": { … }`, the `name` field gone), because the
|
|
1461
|
+
editor's default skin holds no skin placeholders: an attachment there hangs on
|
|
1462
|
+
the slot and is known by its name alone. The slot's setup `attachment: "patch"`
|
|
1463
|
+
now names a key the default skin does not have, so the default skin **draws
|
|
1464
|
+
nothing** — `diff` read `attachments.names 3/5`, `check` read 98.52 mean MAE
|
|
1465
|
+
with `drewSlots: 0` on that chain, and `validate --profile spine` stayed green
|
|
1466
|
+
throughout.
|
|
1467
|
+
- **Leave it as its placeholder** (no `name`, which is the obvious repair) and
|
|
1468
|
+
the editor **refuses the import**:
|
|
1469
|
+
|
|
1470
|
+
```
|
|
1471
|
+
ERROR: Unable to import skeleton.
|
|
1472
|
+
Cause: [error] Error reading attachment: mike/patch (nSX)
|
|
1473
|
+
Cause: [error] Multiple attachments have the same name:
|
|
1474
|
+
patch
|
|
1475
|
+
patch
|
|
1476
|
+
```
|
|
1477
|
+
|
|
1478
|
+
In one slot, a default-skin attachment name and a named skin's placeholder name
|
|
1479
|
+
are the same namespace, and both are `patch`.
|
|
1480
|
+
|
|
1481
|
+
⇒ **The rule: move the shared art into a named skin.** Call it `base`. Every
|
|
1482
|
+
filler of that placeholder is then a named skin, rigc composes all of them, and
|
|
1483
|
+
the names are unique within the slot — which is all
|
|
1484
|
+
[#541](https://github.com/firejune/rigc/issues/541) needed: `base/patch`,
|
|
1485
|
+
`zulu/patch` and `mike/patch` are three names. That shape is the one the editor
|
|
1486
|
+
does hold: the same three fillers in named skins imported, exported and measured
|
|
1487
|
+
**0.00 mean MAE** with names and paths intact.
|
|
1488
|
+
|
|
1489
|
+
📎 **The earlier reading, kept because it was reasonable and wrong.** Between the
|
|
1490
|
+
two trips this guide said *compose off the default skin only* — keep the default
|
|
1491
|
+
skin's entry as its placeholder and name the others. Trip 7 supported it and trip
|
|
1492
|
+
8 refuted it: that is the spelling the editor refuses at the door. There is no
|
|
1493
|
+
third spelling, which is why this is a refusal rather than a naming scheme.
|
|
1494
|
+
|
|
1445
1495
|
Three things to know about it and nothing to author:
|
|
1446
1496
|
|
|
1447
1497
|
- **`path` is restated, and it has to be.** `path` defaults to the attachment's
|
|
@@ -1454,6 +1504,9 @@ Three things to know about it and nothing to author:
|
|
|
1454
1504
|
- **A composed name that collides is a compile error, not a surprise.** If some
|
|
1455
1505
|
other placeholder in the same slot is literally called `zulu/patch`, rigc refuses
|
|
1456
1506
|
and names both sites rather than emitting two attachments with one name again.
|
|
1507
|
+
The walk covers every *uncontested* entry's plain name too, the default skin's
|
|
1508
|
+
included — a name that composed nothing can still be the one another skin
|
|
1509
|
+
composes.
|
|
1457
1510
|
(`/` is the separator because it appears in **0** of the 160 placeholder names and
|
|
1458
1511
|
159 atlas region names in `examples/` and `gallery/`, where `-` appears in 85 and
|
|
1459
1512
|
`_` in 37.)
|
|
@@ -1523,7 +1576,7 @@ curve instead of in the keys.
|
|
|
1523
1576
|
| --- | --- |
|
|
1524
1577
|
| `bones` | at least one, in the order they ride the path |
|
|
1525
1578
|
| `slot` | **required.** The slot whose path attachment they follow (§3.4) |
|
|
1526
|
-
| `positionMode` | default `"Percent"`: `position` is a fraction of the arc
|
|
1579
|
+
| `positionMode` | default `"Percent"`: `position` is a fraction of the measured `lengths` total — **not** of the arc, see §10.6. `"Fixed"` makes it world units, which is the mode a wrong total is visible in |
|
|
1527
1580
|
| `spacingMode` | default `"Length"` — `Length`, `Fixed`, `Percent` or `Proportional` |
|
|
1528
1581
|
| `rotateMode` | default `"Tangent"`: each bone turns to the curve's tangent where it sits. `"Chain"`, `"ChainScale"` |
|
|
1529
1582
|
| `rotation` | default 0. Degrees added after the path's own rotation |
|
|
@@ -3344,7 +3397,7 @@ tell a working traversal from a plausible one.
|
|
|
3344
3397
|
|
|
3345
3398
|
| Group | `property` | Channels | Note |
|
|
3346
3399
|
| --- | --- | --- | --- |
|
|
3347
|
-
| `path` | `position` | 1 | a fraction of the arc
|
|
3400
|
+
| `path` | `position` | 1 | a fraction of the measured `lengths` total (§10.6 — not the arc), or world units under `positionMode: "fixed"` |
|
|
3348
3401
|
| `path` | `spacing` | 1 | in the unit `spacingMode` chose |
|
|
3349
3402
|
| `path` | `mix` | **3** | `[mixRotate, mixX, mixY]` in one key, so a raw `curve` is 12 numbers |
|
|
3350
3403
|
| `slider` | `time` | 1 | the bone-less slider's own time. A slider WITH a bone takes its time from the bone and this timeline is not what drives it |
|
|
@@ -3529,7 +3582,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
|
|
|
3529
3582
|
| `animation "A": "position" is a path constraint timeline, and this track names no constraint` | §4.12 — put the name in `"path"` |
|
|
3530
3583
|
| `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
3584
|
| `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
|
-
| `
|
|
3585
|
+
| `slot "patch": placeholder "patch" is filled by the "default" skin AND by skins "zulu", "mike", and the Spine editor has no way to hold that … Move the default skin's entry for this slot into a named skin — call it "base"` | **R12** — do what it says: move that entry out of `default` into a named skin. The editor has no representation for a placeholder the default skin shares with a named one, in either spelling, and §3.4.2 has both measurements. Renaming the placeholder does not help; the shape is what is refused |
|
|
3586
|
+
| `N attachment name collision(s): a placeholder that more than one skin fills is emitted with the name "<skin>/<placeholder>" … slot "patch": skin "base" 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 a composed name is one another entry in the same slot already answers to — including a plain name in the default skin, which composed nothing. Both sites are named; either rename ends it |
|
|
3533
3587
|
|
|
3534
3588
|
### 5.2 Assertions — the gate
|
|
3535
3589
|
|
|
@@ -5015,9 +5069,19 @@ frames. Every line is marked with where it comes from:
|
|
|
5015
5069
|
|
|
5016
5070
|
- 📗 **stated** — quoted or paraphrased from the page linked in the line.
|
|
5017
5071
|
- 🧩 **inferred** — this guide's reading of those pages. Spine does not say it.
|
|
5018
|
-
- 🔬 **observed** — read off the editor's own export of a rigc build
|
|
5019
|
-
|
|
5020
|
-
|
|
5072
|
+
- 🔬 **observed** — read off the editor's own export of a rigc build, not from a
|
|
5073
|
+
page. Two round trips stand behind these: [issue
|
|
5074
|
+
#285](https://github.com/firejune/rigc/issues/285) (Spine 4.3.23) and the
|
|
5075
|
+
eight-rig trip of 2026-09-16 (Spine **4.3.26**), whose findings are collected in
|
|
5076
|
+
§10.6. ⚠️ This legend said *"used only where rigc now emits the same thing"*,
|
|
5077
|
+
which was true while every observation had already been adopted; §10.6 then
|
|
5078
|
+
carried one that had **not** been — the path `lengths` disagreement — so an
|
|
5079
|
+
observation is now marked by where it was read, and each says for itself
|
|
5080
|
+
whether rigc agrees with it. ⭐ That outstanding one has since been adopted
|
|
5081
|
+
([#560](https://github.com/firejune/rigc/issues/560)) and the legend is kept in
|
|
5082
|
+
this shape anyway: the reason to mark an observation by its source rather than
|
|
5083
|
+
by whether rigc follows it is that the second fact goes stale and the first
|
|
5084
|
+
does not.
|
|
5021
5085
|
|
|
5022
5086
|
### 10.1 Structure
|
|
5023
5087
|
|
|
@@ -5596,6 +5660,37 @@ never write any of them by hand — and rigc's own output always carries all
|
|
|
5596
5660
|
three, because the editor's *import* treats their absence as an export made
|
|
5597
5661
|
without the box and rebuilds the hull on its own.
|
|
5598
5662
|
|
|
5663
|
+
🔬 🚨 **And the CLI's default export has that box ON, so for anything driven from
|
|
5664
|
+
the command line the ⇒ above is the exception rather than the case.** The
|
|
5665
|
+
sentence is still true of an export made without the box; what is measured is
|
|
5666
|
+
that `-e json` with no export-settings file does not make one. Every field on the
|
|
5667
|
+
nonessential list came back present, unchanged and not zero on round trip 6
|
|
5668
|
+
(2026-09-16, Spine 4.3.26): the header's `fps` (24 on `fields`, the one rig that
|
|
5669
|
+
declares it) and `images`; a mesh's `width`/`height` (64/48) and `edges`
|
|
5670
|
+
(16 entries, identical); the editor colours of a **bounding box** (`3cff6bff`), a
|
|
5671
|
+
**clipping** polygon (`ff3c6bff`) and a **path** (`ff6b3cff`); and a bone's
|
|
5672
|
+
`icon` (`circle`) with its `color`. All eight exports also carry
|
|
5673
|
+
`"audio": "./audio"` — a field rigc never wrote and none of those rigs has any
|
|
5674
|
+
use for — which is the list's own last member arriving unasked. The runtime says
|
|
5675
|
+
the same thing from the other side: `PathAttachment.js`'s doc comment on `color`
|
|
5676
|
+
reads *"Available only when nonessential data was exported"*, and the colour is
|
|
5677
|
+
there.
|
|
5678
|
+
|
|
5679
|
+
📗 The CLI page documents the form but not the setting: *"If `json` or `binary` is
|
|
5680
|
+
specified instead of a path to an export settings JSON file, then a JSON or
|
|
5681
|
+
binary export is performed using default settings"* —
|
|
5682
|
+
[Command line interface](https://esotericsoftware.com/spine-command-line-interface).
|
|
5683
|
+
❓ **Neither that page nor the Export page states whether nonessential is on in
|
|
5684
|
+
those defaults**, so the answer above is measured here and documented nowhere.
|
|
5685
|
+
⇒ In practice: do not plan around fields being dropped. An export you did not
|
|
5686
|
+
personally make without the box is an export that has them, and the round trip is
|
|
5687
|
+
therefore **richer** than the build rather than poorer — which is why #368's
|
|
5688
|
+
hull-and-edges degradation does not return on a second trip (no import warning in
|
|
5689
|
+
any of the eight `roundtrip.log`s, and a five-vertex mesh's `hull: 4` came back
|
|
5690
|
+
`4` rather than recomputed to `5`). A nonessential-**off** trip would need an
|
|
5691
|
+
export-settings JSON, and ❓ the key name inside that file is not documented
|
|
5692
|
+
either.
|
|
5693
|
+
|
|
5599
5694
|
⚠️ **A region's `width`/`height` are not on that list.** They are documented with no
|
|
5600
5695
|
*"assume … if omitted"* default — the same fact R5 states from the parser's side:
|
|
5601
5696
|
omit them in raw JSON and every UV collapses, in silence. Name an `image`.
|
|
@@ -5606,7 +5701,136 @@ omitted"* — and **rigc deliberately does the opposite** (R1, §2). Writing `x:
|
|
|
5606
5701
|
legitimate here. The habit worth carrying over is not *omit defaults*, it is
|
|
5607
5702
|
*declare only what the shot needs*.
|
|
5608
5703
|
|
|
5609
|
-
### 10.6 What
|
|
5704
|
+
### 10.6 What a round trip gives back
|
|
5705
|
+
|
|
5706
|
+
Everything above is what the editor **does**. This is what it **returns** — which
|
|
5707
|
+
matters to you for one reason: a construct nobody has carried through the editor
|
|
5708
|
+
is a construct that might vanish there, and an agent cannot see that it did.
|
|
5709
|
+
|
|
5710
|
+
🔬 The source is one run: eight discriminator rigs, each built to isolate a group
|
|
5711
|
+
of fields, compiled by rigc **0.21.0** (emitting 4.3.13), imported into a licensed
|
|
5712
|
+
Spine **4.3.26** through the documented CLI and exported back on 2026-09-16, with
|
|
5713
|
+
the predictions written down before anything was opened. **Seven of the eight came
|
|
5714
|
+
back differing from their build in three header fields and nothing else** —
|
|
5715
|
+
`hash` and `audio`, which the editor adds, and `spine`, which it stamps with its
|
|
5716
|
+
own version. The eighth is the path rig, and it is the last bullet here.
|
|
5717
|
+
|
|
5718
|
+
⚠️ *"Nothing else"* is under two normalisations, both of which are the exporter
|
|
5719
|
+
being ordinary rather than the editor changing anything: **float spelling** (`48`
|
|
5720
|
+
comes back `48.0`) and **omitted defaults** — the export drops any field equal to
|
|
5721
|
+
its parser default, so the header loses `x: 0` and `y: 0`, a bone loses `x: 0`, and
|
|
5722
|
+
a key at t=0 loses its `"time": 0`. Every name-keyed object is also re-sorted, per
|
|
5723
|
+
§10.1. None of those is a loss of information, and each is worth knowing before
|
|
5724
|
+
you read a `diff`.
|
|
5725
|
+
|
|
5726
|
+
⚠️ Read every line below as *this construct survived*, never as *this construct is
|
|
5727
|
+
recommended*. §10.1–§10.5 are the recommendations; this subsection is only the
|
|
5728
|
+
evidence that the format will carry what you write.
|
|
5729
|
+
|
|
5730
|
+
- 🔬 **A transform constraint survives whole.** 4.3's `source` plus its
|
|
5731
|
+
`properties` map — including a nested `to` with `offset`, `max` and `scale` —
|
|
5732
|
+
came back field for field, with `localSource`, `localTarget`, `additive`,
|
|
5733
|
+
`clamp`, `mixRotate` and `mixY` beside it.
|
|
5734
|
+
- 🔬 **The `transform` timeline survives** — the group shape that maps a
|
|
5735
|
+
constraint name straight to a key array (§4.10), with its per-key mixes and
|
|
5736
|
+
curves intact.
|
|
5737
|
+
- 🔬 **`shear`, `shearx` and `sheary` bone timelines survive**, paired and
|
|
5738
|
+
single-axis, with both channels of a paired key and all their curve control
|
|
5739
|
+
points.
|
|
5740
|
+
- 🔬 **A bone's setup `scaleX`, `scaleY`, `shearX`, `shearY` and `inherit`
|
|
5741
|
+
survive**, a non-default `inherit` included — `noScale`, `onlyTranslation` and
|
|
5742
|
+
`noRotationOrReflection` were all carried on one rig.
|
|
5743
|
+
- 🔬 ⭐ **A bone's `color` and `icon` survive** — 4.3's bone icons round-trip
|
|
5744
|
+
(`circle`, `ff7f00ff`). They are nonessential data, so this is also a reading of
|
|
5745
|
+
§10.5's caveat.
|
|
5746
|
+
- 🔬 **The `drawOrder` timeline survives, offsets and all — including the empty
|
|
5747
|
+
key.** A key with no `offsets` restores the setup order (§4.7), and it came back
|
|
5748
|
+
**empty** rather than spelled out as an identity permutation, which is the
|
|
5749
|
+
spelling that would have made every later diff read as a change.
|
|
5750
|
+
- 🔬 **Slot `color`, `dark` and `blend` survive**, `blend: multiply` and
|
|
5751
|
+
`blend: additive` included.
|
|
5752
|
+
- 🔬 **A path constraint's non-default modes survive**: `positionMode: fixed`,
|
|
5753
|
+
`spacingMode: proportional`, `rotateMode: chainScale`.
|
|
5754
|
+
- 🔬 **A path attachment's `closed: true` and `constantSpeed: false` survive**, and
|
|
5755
|
+
so do its `position`, `spacing` and three-channel `mix` timelines — all three
|
|
5756
|
+
channels of every `mix` key, and all twelve curve numbers on each key that
|
|
5757
|
+
carries a curve. ⚠️ Its `lengths` did **not**, which is the last bullet — and
|
|
5758
|
+
since [#560](https://github.com/firejune/rigc/issues/560) they do, because rigc
|
|
5759
|
+
now emits the numbers the editor recomputes rather than numbers near them.
|
|
5760
|
+
- 🔬 **`physics.mix` and `physics.reset` timelines survive**, the `reset` key
|
|
5761
|
+
included — a key that carries a time and no value at all — and so does the
|
|
5762
|
+
physics constraint's setup `mix`.
|
|
5763
|
+
- 🔬 **A time-driven slider survives** — one with no `bone`, carrying `loop`, a
|
|
5764
|
+
setup `time` and a setup `mix`, with both its `time` and `mix` timelines.
|
|
5765
|
+
- 🔬 **`boundingbox` and `clipping` attachments survive whole**: `vertexCount`,
|
|
5766
|
+
`vertices`, the clipping `end` slot, `convex`, and both editor colours.
|
|
5767
|
+
- 🔬 **An unweighted mesh survives and its `hull` is kept, not recomputed** — a
|
|
5768
|
+
five-vertex mesh declaring `hull: 4` came back `4`, with its `edges`, `color`,
|
|
5769
|
+
`uvs` and `triangles` unchanged. Beside it, on the same rig: the header's
|
|
5770
|
+
`referenceScale`, a region's `rotation` / `scaleX` / `scaleY` / `color`, and an
|
|
5771
|
+
attachment whose `path` differs from its placeholder (the mechanism of
|
|
5772
|
+
[#552](https://github.com/firejune/rigc/issues/552)) all survive. A `--pack`
|
|
5773
|
+
build round-trips too.
|
|
5774
|
+
|
|
5775
|
+
🚨 **The one thing that did not come back is a path attachment's `lengths`, and it
|
|
5776
|
+
moved the drawing.** The editor recomputes them at export from the geometry —
|
|
5777
|
+
the imported project holds the numbers it was given — and it measures each curve
|
|
5778
|
+
with the **runtime's own four-sample forward difference**, not with an arbitrarily
|
|
5779
|
+
fine one. `PathConstraint`'s `constantSpeed` re-measure is that same computation:
|
|
5780
|
+
its constants are `0.1875 = 3t²`, `0.09375 = 6t³` and `(cx1 − x1) · 0.75 = 3t` at
|
|
5781
|
+
**t = 1/4**, four `Math.sqrt` terms per curve. A 4-sample chord sum over the same
|
|
5782
|
+
control points reproduces the editor to every digit it prints, on a closed path
|
|
5783
|
+
under 4.3.26 (`[152.7006, 305.4012, 458.1019, 610.8025]`) and on an open one under
|
|
5784
|
+
4.3.23 (`[430.8389, 838.0142, 1127.736, …]`).
|
|
5785
|
+
|
|
5786
|
+
⚠️ **That last reading settles the model and cannot settle the spelling.** A
|
|
5787
|
+
4-sample chord sum agrees with the runtime's forward difference to about **nine
|
|
5788
|
+
significant digits** — *below* what float32 can hold, which is why both spellings
|
|
5789
|
+
reproduce both exports exactly, and *above* the six decimals rigc emits, which is
|
|
5790
|
+
why the file can tell them apart. On both rigs above they round apart on the
|
|
5791
|
+
**last** curve, where the running total has accumulated most: `610.802519` against
|
|
5792
|
+
`610.802520`, `1127.735817` against `1127.735818`. So the editor is the evidence
|
|
5793
|
+
for *what* is computed, and only `PathConstraint` itself is evidence for *how*.
|
|
5794
|
+
|
|
5795
|
+
⭐ **rigc emits the forward difference itself** since
|
|
5796
|
+
[#560](https://github.com/firejune/rigc/issues/560) — `pathCurveLengths` in
|
|
5797
|
+
[`src/compile.ts`](../src/compile.ts) is those runtime lines transcribed, down to
|
|
5798
|
+
`Math.sqrt(dx * dx + dy * dy)` rather than `Math.hypot` and `0.16666667` rather
|
|
5799
|
+
than `1 / 6`, both of which change the emitted file. All seven entries of the two
|
|
5800
|
+
exports above now come back at the precision the editor prints them, so a path rig
|
|
5801
|
+
built here and one authored in the editor parameterise identically. ⚠️ The
|
|
5802
|
+
consequence for you is a vocabulary one: `lengths` is **not** an arc length. It
|
|
5803
|
+
sits about 0.5 % below the arc by construction, so a physical quantity — how far a
|
|
5804
|
+
wheel rolls, how long a ribbon is — has to be measured off the curve and not read
|
|
5805
|
+
out of the artifact.
|
|
5806
|
+
|
|
5807
|
+
🔬 **And the editor always writes `vertexCount / 3` entries, computing the
|
|
5808
|
+
wrap-around curve even on an open path** — that open path's fourth entry,
|
|
5809
|
+
`2136.228`, is the closed-chain cumulative. ⚠️ Neither array is wrong: the parser
|
|
5810
|
+
allocates `vertexCount / 3` and copies whatever is there, and `PathConstraint`
|
|
5811
|
+
reads at most `lengths[curveCount]`, so the trailing entry is never read.
|
|
5812
|
+
|
|
5813
|
+
⇒ **What this means for you.** `lengths` is the one number in a path rig you
|
|
5814
|
+
cannot check by looking: `diff` does not compare it, and `A33` asks only that it
|
|
5815
|
+
strictly increase, which any plausible array does. A total that is a fraction of a
|
|
5816
|
+
percent out was measured at **4.9612 mean MAE** on the one rig of that run with a
|
|
5817
|
+
path constraint, against **0.0000** on the other seven. If you are comparing a
|
|
5818
|
+
path rig against an editor reference and everything structural agrees while the
|
|
5819
|
+
picture does not, this is the first place to look.
|
|
5820
|
+
|
|
5821
|
+
⚠️ **What decides whether it moves a pixel is the POSITION mode**, and an earlier
|
|
5822
|
+
reading of this paragraph put it on `spacingMode: proportional`, which is the one
|
|
5823
|
+
mode it cannot be: proportional spacing scales *with* the total, and that is
|
|
5824
|
+
exactly what cancels. The rig that drifted is `positionMode: fixed`, where an
|
|
5825
|
+
absolute `position` is compared against a total that moved. Under
|
|
5826
|
+
`positionMode: percent` the position scales with the total too, so a uniform
|
|
5827
|
+
change cancels out of both — measured across #560, `gallery/ride` is
|
|
5828
|
+
percent/percent and every one of its 74 rendered frames came back **byte
|
|
5829
|
+
identical** on an emitted array all three of whose numbers moved. ⇒ Read a
|
|
5830
|
+
`lengths` disagreement as *certainly wrong data, and visible only under
|
|
5831
|
+
`positionMode: fixed`*.
|
|
5832
|
+
|
|
5833
|
+
### 10.7 What this section does not claim
|
|
5610
5834
|
|
|
5611
5835
|
Conventions that are visible in reference exports but that **no public Spine page
|
|
5612
5836
|
states** are deliberately absent. A guide that asserted them would be handing you an
|
|
@@ -5614,7 +5838,10 @@ answer read off the exports:
|
|
|
5614
5838
|
|
|
5615
5839
|
- any figure for keys per second, or for how key density scales with frame rate;
|
|
5616
5840
|
- which curve type any particular example project or studio actually shipped;
|
|
5617
|
-
- whether a
|
|
5841
|
+
- whether a **corpus** export — one somebody else made, out of the editor's own
|
|
5842
|
+
dialog — was made with Nonessential data checked. ⚠️ §10.5 now answers this for
|
|
5843
|
+
the **CLI's** `-e json`, where it is measured; that measurement says nothing
|
|
5844
|
+
about an export you were handed, and the two must not be read as one;
|
|
5618
5845
|
- how many bones, slots or timelines a rig of a given size ought to have;
|
|
5619
5846
|
- whether a shipped rig prefers automatic Bezier handles or hand-placed ones.
|
|
5620
5847
|
|
package/docs/SPEC_COVERAGE.md
CHANGED
|
@@ -597,19 +597,25 @@ with the member's own `skin: true` — either half alone is refused, because `Sk
|
|
|
597
597
|
| `mesh` | 🟡 | emits `type`, `uvs`, `triangles`, `vertices` (**weighted encoding only**), `hull`, `width`, `height`, `edges` (`compile.ts:1343`, and part 4's rung-6 entry measures it byte-identical to the reference), `path`, `color`. ❌ `sequence`. Unweighted meshes are 🚫 **A20_MESH_WEIGHTS_COHERENT** (`validate.ts:430-433`) |
|
|
598
598
|
| `linkedmesh` | ❌ | deliberately deferred — never appears in the corpus (part 3-1) |
|
|
599
599
|
| `boundingbox` | ✅ | `vertexCount` (required and cross-checked), `vertices` **or** by-name `weights`, `color`. **A33_VERTEX_ATTACHMENT_GEOMETRY** |
|
|
600
|
-
| `path` | ✅ | `vertexCount` (required, and checked as a multiple of 3 — the parser's own `vertexCount / 3` takes a fractional size in silence), `vertices` **or** by-name `weights`, `closed`, `constantSpeed`, `color`, and a **measured** `lengths`: the cumulative setup
|
|
600
|
+
| `path` | ✅ | `vertexCount` (required, and checked as a multiple of 3 — the parser's own `vertexCount / 3` takes a fractional size in silence), `vertices` **or** by-name `weights`, `closed`, `constantSpeed`, `color`, and a **measured** `lengths`: the cumulative setup length at the end of each curve, taken through each influence's own bone and measured as `PathConstraint` measures it — its own four-sample forward difference, which is what the editor writes too and is about 0.5 % below the arc (AUTHORING §10.6) — refused if authored. **A33_VERTEX_ATTACHMENT_GEOMETRY** re-checks the structure and the array's monotonicity. ❌ a `deform` timeline on one |
|
|
601
601
|
| `point` | ❌ | deliberately deferred — never appears in the corpus (part 3-1) |
|
|
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
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`).
|
|
607
|
-
matters — `name` defaults to the placeholder and `path` defaults to
|
|
608
|
-
one placeholder are several attachments with one name, which
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
606
|
+
that more than one skin fills (`compile.ts`'s `composeSkinAttachmentName` and `nameSkinAttachment`).
|
|
607
|
+
Part 1-5 above states why it matters — `name` defaults to the placeholder and `path` defaults to
|
|
608
|
+
`name` — so several skins under one placeholder are several attachments with one name, which
|
|
609
|
+
spine-core accepts and the Spine editor refuses on import
|
|
610
|
+
([#541](https://github.com/firejune/rigc/issues/541)). The composed name is `<skin>/<placeholder>`,
|
|
611
|
+
`path` is restated beside it so the region still resolves, and a placeholder one skin fills is
|
|
612
|
+
emitted with neither. ⚠️ The **`default` skin may not be one of those skins**, and that is a
|
|
613
|
+
`CompileError` rather than an emission rule: the editor holds no placeholder the default skin shares
|
|
614
|
+
with a named one in either spelling — named, the export re-keys the attachment by its name and the
|
|
615
|
+
slot's setup attachment stops resolving; unnamed, the import is refused with `Multiple attachments
|
|
616
|
+
have the same name` ([#567](https://github.com/firejune/rigc/issues/567), Spine 4.3.26, round trips
|
|
617
|
+
7 and 8). Nothing else in the tree carries a `name`: of the twelve editor exports in `examples/`,
|
|
618
|
+
**0** attachments do, because all twelve declare one skin.
|
|
613
619
|
|
|
614
620
|
Mesh geometry is generated by exactly three procedural generators (`mesh.ts`): `buildRingMesh`
|
|
615
621
|
(three concentric rings + hub, outer two pinned), `buildRibbonMesh` (a two-wide strip along a bone
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
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": {
|
package/src/compile.ts
CHANGED
|
@@ -1900,9 +1900,12 @@ export function compile(opts: CompileOptions): CompileResult {
|
|
|
1900
1900
|
// The name is put on AFTER the builder rather than inside it: five
|
|
1901
1901
|
// builders write five shapes, the rule is one rule, and a rule that has
|
|
1902
1902
|
// to be remembered in five places is a rule that will be kept in four.
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1903
|
+
// `null` is an entry that carries no `name` field, which is every
|
|
1904
|
+
// uncontested placeholder. A contested one the DEFAULT skin fills never
|
|
1905
|
+
// reaches here: it is refused above (issue #567), because the editor
|
|
1906
|
+
// holds no such shape in either spelling.
|
|
1907
|
+
const composed = composeSkinAttachmentName(skinName, placeholder, shared?.has(placeholder) === true);
|
|
1908
|
+
perSlot[placeholder] = composed === null ? built : nameSkinAttachment(built, composed, placeholder);
|
|
1906
1909
|
}
|
|
1907
1910
|
tableFor(skinName)[rigSlot.name] = perSlot;
|
|
1908
1911
|
}
|
|
@@ -2528,6 +2531,92 @@ function skinAttachmentName(skinName: string, placeholder: string): string {
|
|
|
2528
2531
|
return `${skinName}${SKIN_ATTACHMENT_SEPARATOR}${placeholder}`;
|
|
2529
2532
|
}
|
|
2530
2533
|
|
|
2534
|
+
/**
|
|
2535
|
+
* The `name` one skin's entry for a placeholder is emitted with, or `null` for
|
|
2536
|
+
* the entries that carry no `name` field at all.
|
|
2537
|
+
*
|
|
2538
|
+
* Stated once, and called by both the emit and `contestedPlaceholders`'
|
|
2539
|
+
* collision walk, because two readings of one rule is how issue #567 happened.
|
|
2540
|
+
* By the time either caller runs, a contested placeholder the **default** skin
|
|
2541
|
+
* fills has already been refused — see `refuseDefaultSkinContest` — so every
|
|
2542
|
+
* entry this composes for is a named skin's.
|
|
2543
|
+
*/
|
|
2544
|
+
function composeSkinAttachmentName(skinName: string, placeholder: string, contested: boolean): string | null {
|
|
2545
|
+
return contested ? skinAttachmentName(skinName, placeholder) : null;
|
|
2546
|
+
}
|
|
2547
|
+
|
|
2548
|
+
/**
|
|
2549
|
+
* Refuse a placeholder that the **default** skin and a named skin both fill.
|
|
2550
|
+
*
|
|
2551
|
+
* 🚨 This is issue #567 and it is not a naming problem, which took two editor
|
|
2552
|
+
* round trips to establish because each of them looked like one.
|
|
2553
|
+
*
|
|
2554
|
+
* ## What the editor's model is, bracketed by two trips
|
|
2555
|
+
*
|
|
2556
|
+
* Both on Spine **4.3.26**, on a rig whose three skins fill one placeholder
|
|
2557
|
+
* `patch` from three PNGs of three sizes, with a second slot `block` that one
|
|
2558
|
+
* skin fills as the fixed point.
|
|
2559
|
+
*
|
|
2560
|
+
* **Trip 7 — the default skin's attachment given a name of its own**
|
|
2561
|
+
* (`"name": "default/patch"`, as issue #552 emitted it). The editor IMPORTS it,
|
|
2562
|
+
* and exports the default skin's entry re-keyed by that name:
|
|
2563
|
+
*
|
|
2564
|
+
* built "default": { "patch": { "patch": { "name": "default/patch", … } } }
|
|
2565
|
+
* exported "default": { "patch": { "default/patch": { … } } }
|
|
2566
|
+
*
|
|
2567
|
+
* The editor's default skin holds no skin placeholders — an attachment there
|
|
2568
|
+
* hangs on the slot and is known by its name alone — so the name becomes the
|
|
2569
|
+
* key. The slot's setup `attachment: "patch"` then resolves in no default-skin
|
|
2570
|
+
* key and the default skin draws NOTHING: `check` read mean MAE 98.52 with
|
|
2571
|
+
* `drewSlots: 0` on the `patch` chain while `validate --profile spine` stayed
|
|
2572
|
+
* green, because the file is well-formed and only the editor's model says what
|
|
2573
|
+
* a key means.
|
|
2574
|
+
*
|
|
2575
|
+
* **Trip 8 — the default skin's attachment left as its placeholder** (no `name`,
|
|
2576
|
+
* the obvious repair). The editor REFUSES the import:
|
|
2577
|
+
*
|
|
2578
|
+
* ERROR: Unable to import skeleton.
|
|
2579
|
+
* Cause: [error] Error reading attachment: mike/patch (nSX)
|
|
2580
|
+
* Cause: [error] Multiple attachments have the same name:
|
|
2581
|
+
* patch
|
|
2582
|
+
* patch
|
|
2583
|
+
*
|
|
2584
|
+
* The default skin's attachment `patch` hangs on the slot; the named skins'
|
|
2585
|
+
* placeholder `patch` is that slot's other child. **In one slot, a default-skin
|
|
2586
|
+
* attachment name and a named skin's placeholder name are the same namespace.**
|
|
2587
|
+
*
|
|
2588
|
+
* ⇒ The two trips close the case: name it and the setup attachment resolves
|
|
2589
|
+
* nowhere, do not name it and the import is refused. **The editor has no
|
|
2590
|
+
* representation for a placeholder the default skin and a named skin both
|
|
2591
|
+
* fill** — its own convention is shared art in the default skin, per-skin art in
|
|
2592
|
+
* placeholders, and a slot's one setup `attachment` string naming one or the
|
|
2593
|
+
* other. There is no third spelling to find, so this is a `CompileError` and not
|
|
2594
|
+
* a scheme, in the shape issue #543 used: refuse by name and say what to do.
|
|
2595
|
+
*
|
|
2596
|
+
* ⚠️ What this does NOT touch, and the trips measured that half too: a
|
|
2597
|
+
* placeholder that two or more NAMED skins fill keeps #552's composition
|
|
2598
|
+
* exactly. Trip 8's second rig — two named skins filling `patch`, the default
|
|
2599
|
+
* skin holding `block` only — imported, exported and measured **0.0000 mean
|
|
2600
|
+
* MAE**, names and paths intact. The remedy this refusal states is that rig:
|
|
2601
|
+
* move the default skin's entry into a named skin.
|
|
2602
|
+
*/
|
|
2603
|
+
function refuseDefaultSkinContest(slotName: string, placeholder: string, skins: readonly string[]): never {
|
|
2604
|
+
const named = skins.filter((skin) => skin !== DEFAULT_SKIN);
|
|
2605
|
+
throw new CompileError(
|
|
2606
|
+
`slot "${slotName}": placeholder "${placeholder}" is filled by the "${DEFAULT_SKIN}" skin AND by ` +
|
|
2607
|
+
`${named.length === 1 ? 'skin' : 'skins'} ${named.map((skin) => `"${skin}"`).join(', ')}, and the Spine ` +
|
|
2608
|
+
'editor has no way to hold that. Measured on 4.3.26 in both spellings: give the default skin\'s attachment a ' +
|
|
2609
|
+
`name of its own ("${DEFAULT_SKIN}${SKIN_ATTACHMENT_SEPARATOR}${placeholder}") and the editor re-keys it by ` +
|
|
2610
|
+
`that name on export, so the slot's setup attachment "${placeholder}" resolves in no default-skin key and the ` +
|
|
2611
|
+
'default skin draws nothing; leave it as the placeholder and the import is refused outright with ' +
|
|
2612
|
+
`"Multiple attachments have the same name: ${placeholder} ${placeholder}", because a default-skin attachment ` +
|
|
2613
|
+
"hangs on the slot beside the named skins' placeholder of that name. Move the default skin's entry for this " +
|
|
2614
|
+
`slot into a named skin — call it "base" — so every skin filling "${placeholder}" is a named one. Two or more ` +
|
|
2615
|
+
'named skins sharing a placeholder is the shape the editor does hold, and rigc composes their names for them ' +
|
|
2616
|
+
'(#541, #552).',
|
|
2617
|
+
);
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2531
2620
|
/**
|
|
2532
2621
|
* Which `(slot, placeholder)` pairs more than one skin fills — and, on the way,
|
|
2533
2622
|
* the refusal that keeps the composed names from colliding with authored ones.
|
|
@@ -2551,7 +2640,7 @@ function skinAttachmentName(skinName: string, placeholder: string): string {
|
|
|
2551
2640
|
* entry given its own `name`** IMPORTS — all four skins. So it is neither the
|
|
2552
2641
|
* skin count nor the timelines; it is one name over several attachments.
|
|
2553
2642
|
*
|
|
2554
|
-
* ## Only the contested pairs are named, and
|
|
2643
|
+
* ## Only the contested pairs are named, and the default skin may not contest
|
|
2555
2644
|
*
|
|
2556
2645
|
* A placeholder one skin fills keeps the emitted shape it has always had: no
|
|
2557
2646
|
* `name`, no `path` it did not already carry. Every rig in this tree declares
|
|
@@ -2559,6 +2648,14 @@ function skinAttachmentName(skinName: string, placeholder: string): string {
|
|
|
2559
2648
|
* multi-skin rig whose skins use distinct placeholders does not move either,
|
|
2560
2649
|
* because nothing there is ambiguous to begin with.
|
|
2561
2650
|
*
|
|
2651
|
+
* ⚠️ A contested placeholder the **default** skin fills is refused before any
|
|
2652
|
+
* of this runs — `refuseDefaultSkinContest`, issue #567 — because two editor
|
|
2653
|
+
* round trips showed the editor holds no such shape in either spelling. So
|
|
2654
|
+
* every entry the walk below composes for belongs to a named skin, and the
|
|
2655
|
+
* emitted name comes off `composeSkinAttachmentName`, which this function calls
|
|
2656
|
+
* rather than restates: the emit and the refusal disagreeing about one name is
|
|
2657
|
+
* the defect both of them exist to prevent.
|
|
2658
|
+
*
|
|
2562
2659
|
* ⚠️ The scope of the editor's uniqueness rule is **not** skeleton-wide, and the
|
|
2563
2660
|
* corpus proves it rather than a hypothesis doing so: `spineboy-pro.json`, which
|
|
2564
2661
|
* the editor wrote, gives the name `head` to a region in slot `head` and to a
|
|
@@ -2596,12 +2693,26 @@ function contestedPlaceholders(
|
|
|
2596
2693
|
const collisions: string[] = [];
|
|
2597
2694
|
for (const [slotName, perSlot] of fillers) {
|
|
2598
2695
|
const shared = new Set([...perSlot].filter(([, skins]) => skins.length > 1).map(([placeholder]) => placeholder));
|
|
2696
|
+
// 🚨 Before anything is composed: a contested placeholder the DEFAULT skin
|
|
2697
|
+
// fills has no representation in the editor at all, in either spelling
|
|
2698
|
+
// (issue #567, round trips 7 and 8). It is refused here rather than emitted,
|
|
2699
|
+
// and the refusal comes first because renaming cannot repair it — the
|
|
2700
|
+
// remedy is a different rig, not a different string.
|
|
2701
|
+
for (const placeholder of shared) {
|
|
2702
|
+
const skins = perSlot.get(placeholder)!;
|
|
2703
|
+
if (skins.includes(DEFAULT_SKIN)) refuseDefaultSkinContest(slotName, placeholder, skins);
|
|
2704
|
+
}
|
|
2599
2705
|
if (shared.size) contested.set(slotName, shared);
|
|
2600
2706
|
/** Emitted attachment name -> the first entry that claimed it. */
|
|
2601
2707
|
const claimed = new Map<string, string>();
|
|
2602
2708
|
for (const [placeholder, skins] of perSlot) {
|
|
2603
2709
|
for (const skinName of skins) {
|
|
2604
|
-
|
|
2710
|
+
// The emitted name, read off the one function that decides it — so the
|
|
2711
|
+
// refusal and the emit cannot drift into two readings. An UNCONTESTED
|
|
2712
|
+
// entry is claimed under its bare placeholder, the default skin's
|
|
2713
|
+
// included: a named skin whose composed name equals it is a collision,
|
|
2714
|
+
// and one this walk sees for the same reason it sees every other.
|
|
2715
|
+
const name = composeSkinAttachmentName(skinName, placeholder, shared.has(placeholder)) ?? placeholder;
|
|
2605
2716
|
const site = `skin "${skinName}" placeholder "${placeholder}"`;
|
|
2606
2717
|
const taken = claimed.get(name);
|
|
2607
2718
|
if (taken === undefined) claimed.set(name, site);
|
|
@@ -2888,19 +2999,6 @@ function setupWorldVertices(
|
|
|
2888
2999
|
return out;
|
|
2889
3000
|
}
|
|
2890
3001
|
|
|
2891
|
-
/**
|
|
2892
|
-
* How many samples per curve the arc-length measurement takes.
|
|
2893
|
-
*
|
|
2894
|
-
* 64 is a choice about accuracy, and the accuracy that matters is against the
|
|
2895
|
-
* runtime rather than against calculus: `PathConstraint` re-measures a
|
|
2896
|
-
* `constantSpeed` path with a **4-sample** forward difference per curve, so the
|
|
2897
|
-
* number here only has to be fine enough that the two agree to well inside the
|
|
2898
|
-
* tolerance anything downstream compares at. It is a constant rather than a
|
|
2899
|
-
* parameter because a per-call sample count would make `lengths` depend on the
|
|
2900
|
-
* caller, and A18 compares two emits byte for byte.
|
|
2901
|
-
*/
|
|
2902
|
-
const PATH_LENGTH_SAMPLES = 64;
|
|
2903
|
-
|
|
2904
3002
|
/**
|
|
2905
3003
|
* The knot-and-handle chain a path attachment's vertices actually form, in the
|
|
2906
3004
|
* runtime's own order (`PathConstraint.computeWorldPositions`).
|
|
@@ -2919,11 +3017,43 @@ function pathChain(points: Array<[number, number]>, closed: boolean): Array<[num
|
|
|
2919
3017
|
}
|
|
2920
3018
|
|
|
2921
3019
|
/**
|
|
2922
|
-
* Cumulative arc length at the end of each curve of the chain, in world units
|
|
3020
|
+
* Cumulative arc length at the end of each curve of the chain, in world units —
|
|
3021
|
+
* **the runtime's own measurement, restated line for line**.
|
|
2923
3022
|
*
|
|
2924
3023
|
* One entry per curve, which is what `lengths[curve]` indexes: the parser walks
|
|
2925
3024
|
* curves with `if (p > lengths[curve]) continue`, and reads `lengths[curveCount]`
|
|
2926
3025
|
* — where `curveCount` is the LAST curve's index — as the total path length.
|
|
3026
|
+
*
|
|
3027
|
+
* ⭐ **What this is not: an approximation of the arc length.** `lengths` is not a
|
|
3028
|
+
* fact about the Bezier, it is the number the consumer of the field computes for
|
|
3029
|
+
* itself when it is not given one. `PathConstraint.computeWorldPositions`
|
|
3030
|
+
* (`PathConstraint.js:289-324` in `@esotericsoftware/spine-core` 4.3.13) measures
|
|
3031
|
+
* a `constantSpeed` path with a cubic **forward difference** taken at `t = 1/4`
|
|
3032
|
+
* — `0.1875 = 3t²`, `0.09375 = 6t³`, `0.75 = 3t`, `0.16666667` standing in for
|
|
3033
|
+
* 1/6 — accumulating four `Math.sqrt` terms per curve into a running
|
|
3034
|
+
* `pathLength`, and writing the running value into `curves[i]` at each curve's
|
|
3035
|
+
* end. The Spine editor's exported `lengths` are that same computation: measured
|
|
3036
|
+
* against two editor exports, one open path from 4.3.23 and one closed path from
|
|
3037
|
+
* 4.3.26, this reproduces every digit the editor printed. So the loop below is a
|
|
3038
|
+
* transcription, not a sampler that happens to agree — and the two are not the
|
|
3039
|
+
* same thing, which is the reason the transcription is here.
|
|
3040
|
+
*
|
|
3041
|
+
* ⚠️ rigc measured this with a 64-chord sum until issue #560, and the comment
|
|
3042
|
+
* that stood here argued the difference was inside anything's tolerance. It was
|
|
3043
|
+
* not: 0.70 % high, uniformly, worth **4.96 px mean MAE** on the round trip of a
|
|
3044
|
+
* rig whose path constraint reads the field. A 4-chord sum is *also* not the
|
|
3045
|
+
* repair — it agrees with the forward difference only to about nine significant
|
|
3046
|
+
* digits, which is below what float32 can hold (so no editor export can tell the
|
|
3047
|
+
* two apart) and above rigc's own six-decimal rounding (so the emitted file can):
|
|
3048
|
+
* on both measured rigs the two spellings differ in `r6` on the LAST curve, where
|
|
3049
|
+
* the accumulated difference is largest. `PS67`–`PS69` in `selftest.ts` compare
|
|
3050
|
+
* this against `PathConstraint`'s own `curves` array read off a posed skeleton,
|
|
3051
|
+
* which is the only oracle that can see that gap.
|
|
3052
|
+
*
|
|
3053
|
+
* 🔒 The transcription is deliberate down to the spelling: `Math.sqrt(dx * dx +
|
|
3054
|
+
* dy * dy)` rather than `Math.hypot`, `0.16666667` rather than `1 / 6`, and the
|
|
3055
|
+
* running total carried across curves rather than restarted. Each of those is a
|
|
3056
|
+
* place where a more accurate line would emit a different file.
|
|
2927
3057
|
*/
|
|
2928
3058
|
function pathCurveLengths(chain: Array<[number, number]>): number[] {
|
|
2929
3059
|
const out: number[] = [];
|
|
@@ -2933,21 +3063,26 @@ function pathCurveLengths(chain: Array<[number, number]>): number[] {
|
|
|
2933
3063
|
const [cx1, cy1] = chain[c + 1];
|
|
2934
3064
|
const [cx2, cy2] = chain[c + 2];
|
|
2935
3065
|
const [x2, y2] = chain[c + 3];
|
|
2936
|
-
|
|
2937
|
-
|
|
2938
|
-
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
2942
|
-
|
|
2943
|
-
|
|
2944
|
-
|
|
2945
|
-
|
|
2946
|
-
|
|
2947
|
-
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
3066
|
+
const tmpx = (x1 - cx1 * 2 + cx2) * 0.1875;
|
|
3067
|
+
const tmpy = (y1 - cy1 * 2 + cy2) * 0.1875;
|
|
3068
|
+
const dddfx = ((cx1 - cx2) * 3 - x1 + x2) * 0.09375;
|
|
3069
|
+
const dddfy = ((cy1 - cy2) * 3 - y1 + y2) * 0.09375;
|
|
3070
|
+
let ddfx = tmpx * 2 + dddfx;
|
|
3071
|
+
let ddfy = tmpy * 2 + dddfy;
|
|
3072
|
+
let dfx = (cx1 - x1) * 0.75 + tmpx + dddfx * 0.16666667;
|
|
3073
|
+
let dfy = (cy1 - y1) * 0.75 + tmpy + dddfy * 0.16666667;
|
|
3074
|
+
total += Math.sqrt(dfx * dfx + dfy * dfy);
|
|
3075
|
+
dfx += ddfx;
|
|
3076
|
+
dfy += ddfy;
|
|
3077
|
+
ddfx += dddfx;
|
|
3078
|
+
ddfy += dddfy;
|
|
3079
|
+
total += Math.sqrt(dfx * dfx + dfy * dfy);
|
|
3080
|
+
dfx += ddfx;
|
|
3081
|
+
dfy += ddfy;
|
|
3082
|
+
total += Math.sqrt(dfx * dfx + dfy * dfy);
|
|
3083
|
+
dfx += ddfx + dddfx;
|
|
3084
|
+
dfy += ddfy + dddfy;
|
|
3085
|
+
total += Math.sqrt(dfx * dfx + dfy * dfy);
|
|
2951
3086
|
out.push(total);
|
|
2952
3087
|
}
|
|
2953
3088
|
return out;
|
package/src/rig.ts
CHANGED
|
@@ -676,12 +676,18 @@ export interface RigClippingAttachment extends RigVertexGeometry {
|
|
|
676
676
|
* six then straddle the knots, and the constraint slides bones along a curve
|
|
677
677
|
* nobody drew.
|
|
678
678
|
*
|
|
679
|
-
* ⚠️ `lengths` is NOT authored here. It is the cumulative
|
|
680
|
-
*
|
|
681
|
-
*
|
|
682
|
-
*
|
|
679
|
+
* ⚠️ `lengths` is NOT authored here. It is the cumulative length at the end of
|
|
680
|
+
* each curve in the SETUP pose, in world units — a measurement of the geometry
|
|
681
|
+
* above, and the same relationship `image` has to `width`/`height`: a restated
|
|
682
|
+
* number can disagree with the vertices, and when it does, a
|
|
683
683
|
* `constantSpeed: false` path traverses a length that is not the length of the
|
|
684
684
|
* curve, silently. So rigc measures it and refuses an authored one by name.
|
|
685
|
+
*
|
|
686
|
+
* 🔸 *Which* length, exactly, is `SpinePathAttachment`'s subject in
|
|
687
|
+
* [`types.ts`](types.ts) and it is not the arc: it is `PathConstraint`'s own
|
|
688
|
+
* four-sample forward difference, about 0.5 % below the arc, which is what the
|
|
689
|
+
* Spine editor writes back too (issue #560). This comment said "arc length"
|
|
690
|
+
* until then.
|
|
685
691
|
*/
|
|
686
692
|
export interface RigPathAttachment extends RigVertexGeometry {
|
|
687
693
|
type: 'path';
|
package/src/types.ts
CHANGED
|
@@ -717,6 +717,20 @@ export interface SpineSlot {
|
|
|
717
717
|
* (`:529`, `:559`), so an attachment given a name and no path resolves its region
|
|
718
718
|
* at the new name and the atlas lookup misses. `nameSkinAttachment` in
|
|
719
719
|
* `compile.ts` is the one place that writes either, and it always writes both.
|
|
720
|
+
*
|
|
721
|
+
* ⚠️ And the **`default` skin may never be one of the skins sharing that
|
|
722
|
+
* placeholder** — a fact about the editor rather than about the format (issue
|
|
723
|
+
* #567, Spine 4.3.26, round trips 7 and 8), and a `CompileError` rather than a
|
|
724
|
+
* spelling. The editor's named skins hold *skin placeholders*, a key holding a
|
|
725
|
+
* named attachment, and come back untouched. Its default skin holds no
|
|
726
|
+
* placeholders: an attachment there hangs on the slot and is known by its name
|
|
727
|
+
* alone. So writing a name there gets it re-keyed by that name on export and
|
|
728
|
+
* the slot's setup `attachment` stops resolving (trip 7), and NOT writing one
|
|
729
|
+
* makes that attachment's name collide with the named skins' placeholder of the
|
|
730
|
+
* same name, which the editor refuses at import (trip 8). Both spellings are
|
|
731
|
+
* measured, so there is no third; `refuseDefaultSkinContest` in `compile.ts` is
|
|
732
|
+
* where that lives, and `composeSkinAttachmentName` beside it decides the name
|
|
733
|
+
* for the skins that are left.
|
|
720
734
|
*/
|
|
721
735
|
export interface SpineRegionAttachment {
|
|
722
736
|
name?: string;
|
|
@@ -804,12 +818,80 @@ export interface SpineClippingAttachment {
|
|
|
804
818
|
/**
|
|
805
819
|
* A composite cubic Bezier, for a path constraint to slide bones along.
|
|
806
820
|
*
|
|
807
|
-
* `lengths` is the cumulative
|
|
808
|
-
*
|
|
809
|
-
*
|
|
821
|
+
* `lengths` is the cumulative length at the end of each curve in the setup pose,
|
|
822
|
+
* measured **the way `PathConstraint` measures it** — a four-sample forward
|
|
823
|
+
* difference per curve (`PathConstraint.js:301-320`), which is also what the
|
|
824
|
+
* Spine editor exports and which reads about **0.5 % below the true arc**. One
|
|
825
|
+
* entry per curve, so `vertexCount / 3 - 1` of them on an open path and
|
|
826
|
+
* `vertexCount / 3` on a closed one. It has no parser default and the parser
|
|
810
827
|
* dereferences `map.lengths.length` unconditionally, so an absent array is one of
|
|
811
828
|
* the format's few loud failures; rigc measures the numbers off the geometry
|
|
812
829
|
* rather than letting a spec restate them.
|
|
830
|
+
*
|
|
831
|
+
* ⚠️ That sentence read *"the cumulative **arc** length"* until issue #560, and
|
|
832
|
+
* the word was load-bearing in the wrong direction: this is not an arc length,
|
|
833
|
+
* and no refinement of the integral converges on it. It is the number the
|
|
834
|
+
* field's own consumer computes when it is not given one. ⇒ Do not derive a
|
|
835
|
+
* physical quantity from it — how far a wheel rolls, how long a ribbon is.
|
|
836
|
+
* `position` is stated against it; arc length is not it.
|
|
837
|
+
*
|
|
838
|
+
* ⚠️ **The EDITOR writes `vertexCount / 3` entries on BOTH — measured**
|
|
839
|
+
* (round trip 6, 2026-09-16, Spine 4.3.26). It computes the wrap-around curve
|
|
840
|
+
* even for an open path: `gallery/ride`'s 12-vertex open path came back with
|
|
841
|
+
* **four** entries on 2026-09-04 (4.3.23) and the fourth, `2136.228`, is the
|
|
842
|
+
* *closed*-chain cumulative. ⚠️ Neither array is wrong, and this is not a case
|
|
843
|
+
* of the editor knowing something rigc does not. `SkeletonJson.js:601` allocates
|
|
844
|
+
* `Utils.newArray(vertexCount / 3, 0)` and copies whatever is there, while
|
|
845
|
+
* `PathConstraint` reads at most `lengths[curveCount]` with
|
|
846
|
+
* `curveCount = verticesLength / 6 − (closed ? 1 : 2)` — index 2 on that path.
|
|
847
|
+
* The trailing entry the editor adds to an open path is never read by anything.
|
|
848
|
+
*
|
|
849
|
+
* 🚨 **What the editor writes INTO those entries is its own measurement, and it
|
|
850
|
+
* is the runtime's, not calculus'** (issue #560, measured on the same trip).
|
|
851
|
+
* `PathConstraint`'s `constantSpeed` re-measure is a four-sample forward
|
|
852
|
+
* difference per curve — its constants are `0.1875 = 3t²`, `0.09375 = 6t³` and
|
|
853
|
+
* `(cx1 − x1) · 0.75 = 3t` at **t = 1/4**, accumulating four `Math.sqrt` terms —
|
|
854
|
+
* and the editor's stored `lengths` are that same computation. A 4-sample chord
|
|
855
|
+
* sum over the same control points reproduces the editor to every digit it
|
|
856
|
+
* prints, on two rigs and two editor builds: `pathmodes` (closed, 4.3.26) came
|
|
857
|
+
* back `[152.7006, 305.4012, 458.1019, 610.8025]` and `ride` (open, 4.3.23)
|
|
858
|
+
* `[430.8389, 838.0142, 1127.736, …]`. It is a recomputation at **export** — the
|
|
859
|
+
* inflated `.spine` project holds the imported numbers verbatim — so a path rig
|
|
860
|
+
* is re-parameterised by the trip rather than corrupted by it.
|
|
861
|
+
*
|
|
862
|
+
* ⇒ **rigc emits that computation, not a sampler aimed at it** (issue #560).
|
|
863
|
+
* `pathCurveLengths` in [`compile.ts`](compile.ts) is `PathConstraint.js:301-320`
|
|
864
|
+
* transcribed, down to `Math.sqrt(dx * dx + dy * dy)` rather than `Math.hypot`
|
|
865
|
+
* and `0.16666667` rather than `1 / 6`; `PS67`–`PS69` in `selftest.ts` hold it
|
|
866
|
+
* there by requiring it to reproduce a real `PathConstraint.curves` array **bit
|
|
867
|
+
* for bit** on the runtime's own posed chain. Measured after the change, all
|
|
868
|
+
* seven entries of both editor exports above come back at the precision the
|
|
869
|
+
* editor prints them.
|
|
870
|
+
*
|
|
871
|
+
* ⚠️ This paragraph used to point at a constant — `PATH_LENGTH_SAMPLES` — and ask
|
|
872
|
+
* *how finely rigc should sample*. There is no such constant now, and the
|
|
873
|
+
* question was the wrong one. The chord-sum reading above is true and it is not
|
|
874
|
+
* sufficient: a 4-sample chord sum agrees with the forward difference to about
|
|
875
|
+
* **nine significant digits**, which is *below* what float32 can hold — so no
|
|
876
|
+
* editor export can tell the two apart, and that reading can only settle the
|
|
877
|
+
* MODEL — and *above* rigc's six-decimal rounding, so the emitted file can. On
|
|
878
|
+
* both rigs above the two spellings round apart on the **last** curve, where the
|
|
879
|
+
* running total has accumulated most: `610.802519` against `610.802520`, and
|
|
880
|
+
* `1127.735817` against `1127.735818`. So the editor is the evidence for what is
|
|
881
|
+
* being computed and only the runtime is evidence for how.
|
|
882
|
+
*
|
|
883
|
+
* 📌 For the record of what the disagreement cost when it was found: the build
|
|
884
|
+
* rigc **0.21.0** emitted for `pathmodes` sat a uniform **0.70 %** above the
|
|
885
|
+
* editor's four numbers, and `check` read **4.9612 mean MAE** against 0.0000 on
|
|
886
|
+
* the seven rigs of that run without a path. ⚠️ What decides whether that moves a
|
|
887
|
+
* pixel is the POSITION mode, not the spacing mode: `pathmodes` is
|
|
888
|
+
* `positionMode: fixed`, where an absolute `position` is compared against a total
|
|
889
|
+
* that scaled, so the bone slides. Under `positionMode: percent` a uniform scale
|
|
890
|
+
* cancels out of both the position and the spacing — `gallery/ride` is
|
|
891
|
+
* percent/percent and every one of its 74 rendered frames came back **byte
|
|
892
|
+
* identical** across this change, on an emitted array all three of whose numbers
|
|
893
|
+
* moved. `A33_VERTEX_ATTACHMENT_GEOMETRY` asks only that the array strictly
|
|
894
|
+
* increase, which both arrays do, and `diff` does not compare it at all.
|
|
813
895
|
*/
|
|
814
896
|
export interface SpinePathAttachment {
|
|
815
897
|
type: 'path';
|
|
@@ -932,6 +1014,28 @@ export interface SpineSkeletonJson {
|
|
|
932
1014
|
* four-skin rig on import without a word: that refusal was rigc's own
|
|
933
1015
|
* harness discarding the editor's stderr, and the editor had named the
|
|
934
1016
|
* cause all along.
|
|
1017
|
+
*
|
|
1018
|
+
* - ✅ **What round trip 6 added to the `constraints` line is TYPES, not
|
|
1019
|
+
* order** (2026-09-16, Spine 4.3.26, eight rigs). The three that stood here
|
|
1020
|
+
* were `gallery/look`'s, and they are two types: `yaw` and `tilt`
|
|
1021
|
+
* (**slider**) and `whip` (**physics**). The trip carried a `transform`
|
|
1022
|
+
* with its whole 4.3 `source` + `properties` map, a `path` with three
|
|
1023
|
+
* non-default modes, another `physics` and another `slider`, and every one
|
|
1024
|
+
* came back field for field — so the array is now measured over **four**
|
|
1025
|
+
* of the format's types. `ik` is in none of the rigs anybody has
|
|
1026
|
+
* round-tripped, which is why the count is four and not five.
|
|
1027
|
+
*
|
|
1028
|
+
* ⚠️ **None of round 6's own constraint arrays can tell order preserved
|
|
1029
|
+
* from a name sort, and reading them as if they could would be #537's
|
|
1030
|
+
* mistake in a second collection.** The three rigs that carry constraints
|
|
1031
|
+
* hold `aim, hold` (xform), `hold, knob` (physlider) and `ride` alone
|
|
1032
|
+
* (pathmodes). All three came back in the order they were given — and all
|
|
1033
|
+
* three were *already* in name order, so a re-sort and a preservation are
|
|
1034
|
+
* the same picture there, exactly as a one-element array is for `skins`
|
|
1035
|
+
* above. ⇒ The order claim still rests entirely on `gallery/look`, whose
|
|
1036
|
+
* build order `yaw, tilt, whip` is **not** name order (`tilt < whip < yaw`)
|
|
1037
|
+
* and which #539 read back unchanged. That one rig is load-bearing and
|
|
1038
|
+
* nothing in this tree re-takes it.
|
|
935
1039
|
*/
|
|
936
1040
|
events?: Record<string, SpineEvent>;
|
|
937
1041
|
animations: Record<
|
|
@@ -384,7 +384,7 @@ function checkFigures(reportPath: string): string[] {
|
|
|
384
384
|
}
|
|
385
385
|
|
|
386
386
|
/** The shape of a skeleton file, for the field-by-field comparison. */
|
|
387
|
-
interface Shape {
|
|
387
|
+
export interface Shape {
|
|
388
388
|
spine: string;
|
|
389
389
|
bones: number;
|
|
390
390
|
slots: number;
|
|
@@ -395,16 +395,43 @@ interface Shape {
|
|
|
395
395
|
images: string | null;
|
|
396
396
|
}
|
|
397
397
|
|
|
398
|
-
|
|
398
|
+
/**
|
|
399
|
+
* How many constraints of each `type`, read out of 4.3's single array.
|
|
400
|
+
*
|
|
401
|
+
* 🚨 This used to count four FIXED keys off the 4.1-era top-level arrays —
|
|
402
|
+
* `d.ik`, `d.transform`, `d.path`, `d.physics` — none of which a 4.3 file has
|
|
403
|
+
* (issue #561). Every row therefore read `0 -> 0` on every trip this tool has
|
|
404
|
+
* ever run, so the summary's answer to *"did the editor drop a constraint"* was
|
|
405
|
+
* a constant, printed with the same confidence as the rows that measure
|
|
406
|
+
* something. Measured on `round6/out/pathmodes/build/skeleton.json`, whose
|
|
407
|
+
* top-level keys are `skeleton, bones, slots, skins, animations, constraints`:
|
|
408
|
+
* the old reads returned `{ik: 0, transform: 0, path: 0, physics: 0}` beside one
|
|
409
|
+
* path constraint named `ride`.
|
|
410
|
+
*
|
|
411
|
+
* ⭐ The keys are now the types actually **present**, which is why `shapeDiff`
|
|
412
|
+
* unions them: a type that vanishes has a key on one side only, and a fixed key
|
|
413
|
+
* list would have to be kept by hand against a format that added `slider` in
|
|
414
|
+
* 4.3 and can add another.
|
|
415
|
+
*/
|
|
416
|
+
function constraintsByType(list: ReadonlyArray<{ type?: unknown }>): Record<string, number> {
|
|
417
|
+
const counts: Record<string, number> = {};
|
|
418
|
+
for (const c of list) {
|
|
419
|
+
// A constraint with no usable `type` is what `A01` exists to catch, so it is
|
|
420
|
+
// counted under a name rather than dropped — a row nobody can read beats a
|
|
421
|
+
// row nobody gets.
|
|
422
|
+
const type = typeof c?.type === 'string' && c.type !== '' ? c.type : '(no type)';
|
|
423
|
+
counts[type] = (counts[type] ?? 0) + 1;
|
|
424
|
+
}
|
|
425
|
+
return counts;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export function shapeOf(path: string): Shape {
|
|
399
429
|
interface Skel {
|
|
400
430
|
skeleton?: { spine?: string; images?: string };
|
|
401
431
|
bones?: unknown[];
|
|
402
432
|
slots?: unknown[];
|
|
403
433
|
skins?: Array<{ attachments?: Record<string, Record<string, unknown>> }>;
|
|
404
|
-
|
|
405
|
-
transform?: unknown[];
|
|
406
|
-
path?: unknown[];
|
|
407
|
-
physics?: unknown[];
|
|
434
|
+
constraints?: Array<{ type?: unknown }>;
|
|
408
435
|
animations?: Record<string, Record<string, unknown>>;
|
|
409
436
|
}
|
|
410
437
|
const d = JSON.parse(readFileSync(path, 'utf8')) as Skel;
|
|
@@ -418,12 +445,7 @@ function shapeOf(path: string): Shape {
|
|
|
418
445
|
(n, s) => n + Object.values(s.attachments ?? {}).reduce((m, v) => m + Object.keys(v).length, 0),
|
|
419
446
|
0,
|
|
420
447
|
),
|
|
421
|
-
constraints:
|
|
422
|
-
ik: (d.ik ?? []).length,
|
|
423
|
-
transform: (d.transform ?? []).length,
|
|
424
|
-
path: (d.path ?? []).length,
|
|
425
|
-
physics: (d.physics ?? []).length,
|
|
426
|
-
},
|
|
448
|
+
constraints: constraintsByType(d.constraints ?? []),
|
|
427
449
|
animations: Object.keys(d.animations ?? {}).sort(),
|
|
428
450
|
timelineKinds: [...kinds].sort(),
|
|
429
451
|
images: d.skeleton?.images ?? null,
|
|
@@ -431,7 +453,7 @@ function shapeOf(path: string): Shape {
|
|
|
431
453
|
}
|
|
432
454
|
|
|
433
455
|
/** The rows where the two shapes disagree — what the editor rewrote. */
|
|
434
|
-
function shapeDiff(before: Shape, after: Shape): string[] {
|
|
456
|
+
export function shapeDiff(before: Shape, after: Shape): string[] {
|
|
435
457
|
const rows: string[] = [];
|
|
436
458
|
const cmp = (field: string, a: unknown, b: unknown): void => {
|
|
437
459
|
const x = JSON.stringify(a);
|
|
@@ -443,7 +465,13 @@ function shapeDiff(before: Shape, after: Shape): string[] {
|
|
|
443
465
|
cmp('bones', before.bones, after.bones);
|
|
444
466
|
cmp('slots', before.slots, after.slots);
|
|
445
467
|
cmp('attachments', before.attachments, after.attachments);
|
|
446
|
-
|
|
468
|
+
// The UNION of both sides' types, not the build's: a constraint type the build
|
|
469
|
+
// has and the export does not is the case this row exists for, and iterating
|
|
470
|
+
// one side's keys would also miss a type only the export carries. Absent reads
|
|
471
|
+
// as 0 so the row says `build 1 -> export 0` rather than naming `undefined`.
|
|
472
|
+
for (const k of [...new Set([...Object.keys(before.constraints), ...Object.keys(after.constraints)])].sort()) {
|
|
473
|
+
cmp(`${k} constraints`, before.constraints[k] ?? 0, after.constraints[k] ?? 0);
|
|
474
|
+
}
|
|
447
475
|
cmp('animations', before.animations, after.animations);
|
|
448
476
|
cmp('timeline kinds', before.timelineKinds, after.timelineKinds);
|
|
449
477
|
return rows;
|
|
@@ -489,6 +517,82 @@ function main(): void {
|
|
|
489
517
|
|
|
490
518
|
if (!existsSync(source)) fail(`no skeleton.json in the build directory ${opts.build}`);
|
|
491
519
|
|
|
520
|
+
// 🚨 The art the EDITOR will look for, checked before the editor is started —
|
|
521
|
+
// issue #562. The editor's JSON import reads `skeleton.images` and finds each
|
|
522
|
+
// attachment's file by name under it; it never reads an atlas (#370, measured
|
|
523
|
+
// at the MISSING wall). So a build whose `images` no longer resolves imports
|
|
524
|
+
// as a skeleton with no pixels, and the run goes on to gate, `diff`, render
|
|
525
|
+
// and `check` a candidate whose every region is blank — a green-looking
|
|
526
|
+
// measurement of nothing, or a red one blaming the editor.
|
|
527
|
+
//
|
|
528
|
+
// ⚠️ This is a `--pack` build's ordinary shape, not a corner case. `--pack`
|
|
529
|
+
// writes ONE shared page into `--out` and no loose parts at all (measured:
|
|
530
|
+
// `round6/out/packed/build/` holds `skeleton.json`, `skeleton.atlas`,
|
|
531
|
+
// `skeleton.png` and nothing else), so `skeletonImagesPath` falls through to
|
|
532
|
+
// the loose parts directory and `images` necessarily points OUT of the build
|
|
533
|
+
// — `"../../../rigs/packed/parts/"` on that run. The build is self-contained
|
|
534
|
+
// for a runtime and is not for the editor, and the two directories go their
|
|
535
|
+
// separate ways the moment anybody moves either.
|
|
536
|
+
//
|
|
537
|
+
// 🔒 Why this refuses rather than pointing `images` inside `--out`: there is
|
|
538
|
+
// nothing in there to point AT. The editor would look for `crown.png` beside
|
|
539
|
+
// the skeleton and find one packed page, so the "fix" turns a path that
|
|
540
|
+
// resolves while the parts are in place into one that can never resolve at
|
|
541
|
+
// all. What the editor needs is loose files, which is what `--copy-images`
|
|
542
|
+
// makes — and `--pack --copy-images` is refused by `cli.ts`, correctly,
|
|
543
|
+
// because a packed atlas does not reference loose parts.
|
|
544
|
+
//
|
|
545
|
+
// ⛔ It is checked HERE, before step 1, rather than beside the atlas check
|
|
546
|
+
// further down, because each precondition sits before the step it protects:
|
|
547
|
+
// the atlas's page names matter to the harness's own copy in step 3, and this
|
|
548
|
+
// matters to the editor's import in step 1.
|
|
549
|
+
{
|
|
550
|
+
interface ImagesProbe {
|
|
551
|
+
skeleton?: { images?: string };
|
|
552
|
+
skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; path?: string }>> }>;
|
|
553
|
+
}
|
|
554
|
+
const probe = JSON.parse(readFileSync(source, 'utf8')) as ImagesProbe;
|
|
555
|
+
const declared = probe.skeleton?.images;
|
|
556
|
+
if (declared !== undefined && declared !== '') {
|
|
557
|
+
const imagesDir = resolve(opts.build, declared);
|
|
558
|
+
// Only the two attachment types that read a texture. A boundingbox,
|
|
559
|
+
// clipping or path attachment names no image and would be a false
|
|
560
|
+
// refusal — `src/types.ts` says so for each of them.
|
|
561
|
+
const wanted = new Set<string>();
|
|
562
|
+
for (const skin of probe.skins ?? []) {
|
|
563
|
+
for (const slot of Object.values(skin.attachments ?? {})) {
|
|
564
|
+
for (const [placeholder, att] of Object.entries(slot)) {
|
|
565
|
+
if (att.type !== undefined && att.type !== 'mesh') continue;
|
|
566
|
+
wanted.add(att.path ?? placeholder);
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
const EXTENSIONS = ['.png', '.jpg', '.jpeg'];
|
|
571
|
+
const missing = [...wanted]
|
|
572
|
+
.sort()
|
|
573
|
+
.filter((name) => !EXTENSIONS.some((ext) => existsSync(join(imagesDir, `${name}${ext}`))));
|
|
574
|
+
if (!existsSync(imagesDir)) {
|
|
575
|
+
fail(
|
|
576
|
+
`the build's skeleton.images is "${declared}", which resolves to ${imagesDir} — and there is no ` +
|
|
577
|
+
'directory there. The editor finds a JSON import\'s art by that path and never by the atlas, so the ' +
|
|
578
|
+
'import would produce a skeleton with no pixels and every measurement after it would be of nothing. ' +
|
|
579
|
+
'A `--pack` build is a runtime artifact: its pages are in --out and its `images` names the loose parts ' +
|
|
580
|
+
'it was packed from, so it round-trips only while those parts are where they were at build time. ' +
|
|
581
|
+
'Rebuild with `--copy-images` (which puts the parts beside the skeleton), or put that directory back.',
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
if (missing.length > 0) {
|
|
585
|
+
fail(
|
|
586
|
+
`the build's skeleton.images is "${declared}" (${imagesDir}) and ${missing.length} of ${wanted.size} ` +
|
|
587
|
+
`attachment image(s) are not under it — the first is "${missing[0]}". The editor resolves each ` +
|
|
588
|
+
'attachment by name against that directory and never through the atlas, so those attachments would ' +
|
|
589
|
+
'import with no pixels. If this is a `--pack` build, its one packed page is in --out and its parts are ' +
|
|
590
|
+
'not: rebuild with `--copy-images`, which is the shape whose art travels with the skeleton.',
|
|
591
|
+
);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
492
596
|
rmSync(opts.out, { recursive: true, force: true });
|
|
493
597
|
mkdirSync(join(opts.out, 'export'), { recursive: true });
|
|
494
598
|
mkdirSync(join(opts.out, 'export-cand'), { recursive: true });
|
|
@@ -641,4 +745,11 @@ function main(): void {
|
|
|
641
745
|
process.exit(gate.status === 0 && check.status === 0 ? 0 : 1);
|
|
642
746
|
}
|
|
643
747
|
|
|
644
|
-
|
|
748
|
+
// ⭐ Guarded so `shapeOf` and `shapeDiff` can be READ by a control that has no
|
|
749
|
+
// editor (issue #561). Every other `ERT` case drives this file as a subprocess,
|
|
750
|
+
// which is the right shape for a refusal; step 6's summary is a pure function of
|
|
751
|
+
// two skeleton files, and driving an editor — or four rigc subcommands — to
|
|
752
|
+
// reach it would be paying for a round trip to test arithmetic. Run as a
|
|
753
|
+
// program this is unchanged: `bun tools/editor_roundtrip.ts …` makes this module
|
|
754
|
+
// the entry, so `import.meta.main` is true.
|
|
755
|
+
if (import.meta.main) main();
|