spine-rigc 2.0.0 → 2.0.1
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/README.md +9 -4
- package/cli_core.ts +12 -1
- package/docs/AUTHORING.md +58 -6
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +7 -0
- package/src/assertions/bodies/a06.ts +8 -0
- package/src/assertions/bodies/a19.ts +79 -30
- package/src/assertions/emitted/index.ts +3 -1
- package/src/cli/core_commands.ts +2 -1
- package/src/cli/shared.ts +18 -13
- package/src/compile.ts +63 -0
package/README.md
CHANGED
|
@@ -43,8 +43,9 @@ installed.
|
|
|
43
43
|
| a picture of a key pose | `rigc pose` | where each loose part PNG sits in it, in spec coordinates — the movement between two poses is then yours to key ([docs/MOTION.md](docs/MOTION.md)) |
|
|
44
44
|
| the same picture, and a rig | `rigc chainfit` | the parts `pose` refuses because something is drawn over them — read through the candidate's own draw order and hierarchy, with the share of each part the answer was measured on |
|
|
45
45
|
|
|
46
|
-
Everything in that table needs Bun and this package
|
|
47
|
-
|
|
46
|
+
Everything in that table needs Bun and this package — `preview` and `vote` also
|
|
47
|
+
`@esotericsoftware/spine-core` installed beside it (see below) — and no clone, no
|
|
48
|
+
reference art, no art pipeline, no server.
|
|
48
49
|
|
|
49
50
|
## What rigc is, and what it is not
|
|
50
51
|
|
|
@@ -147,8 +148,12 @@ Bun script, and npm only writes the shim that calls it.
|
|
|
147
148
|
|
|
148
149
|
Installed, the command is `rigc`. The examples below spell it `bun cli.ts`
|
|
149
150
|
because they are written from a clone of this repository (`bun install`, then run
|
|
150
|
-
the CLI in place)
|
|
151
|
-
`bun cli.ts build …`.
|
|
151
|
+
the CLI in place). With `@esotericsoftware/spine-core` installed beside the package
|
|
152
|
+
the two are interchangeable — `rigc build …` is `bun cli.ts build …`. Without it,
|
|
153
|
+
`rigc` runs the entry that links none of the runtime (`bun cli_core.ts` in a clone):
|
|
154
|
+
`build` writes the same files gated without the parse, and a command that needs the
|
|
155
|
+
runtime is refused by name, saying how to get it (that entry's `--help` lists the
|
|
156
|
+
commands it runs). `rigc --version` names the entry that ran.
|
|
152
157
|
|
|
153
158
|
One command is a repository workflow rather than a package one: `bench` measures
|
|
154
159
|
against Spine's official example projects — fetched, never committed — and against
|
package/cli_core.ts
CHANGED
|
@@ -9,18 +9,29 @@
|
|
|
9
9
|
*
|
|
10
10
|
* Which commands those are is not listed here: it is every command whose
|
|
11
11
|
* `runtime` is `false` in the one command table (`src/cli/shared.ts`), and
|
|
12
|
+
* every one whose `runtime` states a second body (`runtime.core`, below), and
|
|
12
13
|
* `--help` prints exactly that set. This file imports nothing that links the
|
|
13
14
|
* runtime — no module it reaches, statically, imports
|
|
14
15
|
* `@esotericsoftware/spine-core` — so it runs with the package absent
|
|
15
16
|
* (`RC24`/`RC25` in `selftest.ts` build that state and hold it). Each command
|
|
16
17
|
* prints and writes what `cli.ts` prints and writes for it.
|
|
17
18
|
*
|
|
19
|
+
* 📦 It is also what an install runs (issue #1061): `bin/rigc.cjs` hands `rigc`
|
|
20
|
+
* to this file wherever `@esotericsoftware/spine-core` does not resolve from the
|
|
21
|
+
* package — a devDependency, so every install until somebody adds it beside —
|
|
22
|
+
* and to `cli.ts` wherever it does. So the texts this entry prints are read in a
|
|
23
|
+
* clone and in an install alike, and where one names the entry that links the
|
|
24
|
+
* runtime it names both routes to it (issue #1072): installed, the same `rigc`
|
|
25
|
+
* once the runtime is installed beside the package; from a source checkout,
|
|
26
|
+
* `bun cli.ts`.
|
|
27
|
+
*
|
|
18
28
|
* ⚠️ What it cannot do, and refuses by name rather than reaching for: an input
|
|
19
29
|
* only the runtime reads — a Spine export, `--poser spine`, a fallback the
|
|
20
30
|
* poser line names, the deform survey of a build the core refuses — and a
|
|
21
31
|
* command whose body is the runtime's (`validate`, `bench`, `bonedist`,
|
|
22
32
|
* `preview`, `vote`). Both say what they needed spine-core for and that this
|
|
23
|
-
* entry links none of it;
|
|
33
|
+
* entry links none of it; the command's refusal also names the two routes to
|
|
34
|
+
* the entry that runs it.
|
|
24
35
|
*
|
|
25
36
|
* `build` is the one name with a body here of its own (issue #1060,
|
|
26
37
|
* `CORE_ENTRY_RUNS`): `cli.ts build`'s body (`runBuild`), writing the same
|
package/docs/AUTHORING.md
CHANGED
|
@@ -139,9 +139,17 @@ green.** A red
|
|
|
139
139
|
run leaves nothing on disk, so there is no half-written artifact to mistake for a
|
|
140
140
|
result. There is no `--no-validate`, and there will not be one.
|
|
141
141
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
`
|
|
142
|
+
The commands in this guide are spelled as a clone of the repository runs them,
|
|
143
|
+
`bun cli.ts …`. Installed, the command is `rigc`, and which entry it runs turns on one
|
|
144
|
+
thing: with `@esotericsoftware/spine-core` installed beside the package, `rigc` is
|
|
145
|
+
`bun cli.ts` and runs every command; without it, `rigc` runs `cli_core.ts` — the
|
|
146
|
+
entry that links none of spine-core, `bun cli_core.ts` in a clone — which refuses by
|
|
147
|
+
name the commands that need the runtime (its `--help` lists the ones it runs).
|
|
148
|
+
`rigc --version` names the entry that ran.
|
|
149
|
+
|
|
150
|
+
That entry's `build` takes the same flags and writes the same files, byte for byte:
|
|
151
|
+
`skeleton.json`, `skeleton.atlas`, `skeleton.model.json`, and the pages `--pack` or
|
|
152
|
+
`--copy-images` put in `--out`.
|
|
145
153
|
Only the gate differs, because the round trip is spine-core's: it runs the model
|
|
146
154
|
side over the document, and the round trip's own rules restated over the text the
|
|
147
155
|
emitter wrote (A01, A02, A05, A07, A16, A31, A35, and A18 over a second compile's
|
|
@@ -1285,6 +1293,43 @@ So what is left refused is short, and each row is a pair two readings of the
|
|
|
1285
1293
|
numbered series that crosses 9 → 10 is keyed **1, 2, … 9, 10, 11, 12**, which is
|
|
1286
1294
|
what the editor does with it — and is not what a codepoint sort does.
|
|
1287
1295
|
|
|
1296
|
+
🔢 **One kind of name the file cannot key in the editor's order: a plain
|
|
1297
|
+
non-negative integer.** `animations` is a JSON object, and an object lists every
|
|
1298
|
+
key that is an array index (`5`, `10` — not `01`, `05`, `-a` or `2b`) before the
|
|
1299
|
+
rest, in ascending order, however it was filled. So for those names the file's
|
|
1300
|
+
order is the editor's with the array-index names moved to the front, and the
|
|
1301
|
+
editor's re-sort on import is not a no-op. That repoints a `slider` exactly when
|
|
1302
|
+
its animation is one of the names that move index, and **that slider is a compile
|
|
1303
|
+
error** naming the slider, both indices and the animation it would come back
|
|
1304
|
+
applying (§5.1). A name list with an array-index name in it and no slider on a
|
|
1305
|
+
moved name builds: the editor re-keys the object by name and nothing else in it
|
|
1306
|
+
refers by position.
|
|
1307
|
+
|
|
1308
|
+
🔁 **Measured 2026-10-02 (issue #1040) — a dated record.** `gallery/look` with its
|
|
1309
|
+
animations named `5, -a, 10, 2b, 01` (from `turn, tilt, sweep` and copies of
|
|
1310
|
+
`sweep` and `tilt`), its sliders `yaw -> "5"` and `tilt -> "-a"`, five bones
|
|
1311
|
+
renamed to the same five names and five events declared under them, built by
|
|
1312
|
+
rigc 2.0.0 with `--copy-images` and round-tripped by `tools/editor_roundtrip.ts`
|
|
1313
|
+
through a licensed editor writing data version 4.3.26. Read off the TEXT of each
|
|
1314
|
+
file (a JSON parser lists array-index keys first and would hide the answer):
|
|
1315
|
+
|
|
1316
|
+
| | the build | the export |
|
|
1317
|
+
| --- | --- | --- |
|
|
1318
|
+
| `animations` | `5, 10, -a, 01, 2b` | **`-a, 01, 2b, 5, 10`** — the comparator's order |
|
|
1319
|
+
| slider `yaw` | `"5"` (index 0) | **`"-a"`** — index 0 of the sorted list |
|
|
1320
|
+
| slider `tilt` | `"-a"` (index 2) | **`"2b"`** — index 2 of the sorted list |
|
|
1321
|
+
| `events` | `5, 10, -a, 2b, 01` | `-a, 01, 2b, 5, 10`; every event key still names its event |
|
|
1322
|
+
| `bones` (an array) | unchanged | unchanged |
|
|
1323
|
+
| an animation's `bones` group | the model's order, array-index names first | sorted by name — by name on both sides, so nothing moves |
|
|
1324
|
+
|
|
1325
|
+
Both sliders moved, each to the name at its old index — the mechanism #535 found
|
|
1326
|
+
with `tilt` as its unmoved control. The gate on the export failed only
|
|
1327
|
+
`A23_PHYSICS_CONSTRAINT_EFFECTIVE`, the editor's known loss of a `rotate`
|
|
1328
|
+
physics constraint ([FACE.md](FACE.md)), and `diff` moved only
|
|
1329
|
+
`animations.curve_kinds` (218 of 222): **neither the gate nor `diff` names the
|
|
1330
|
+
repointed sliders**, and the re-rendered mean absolute error of 8.43–10.41 is the
|
|
1331
|
+
size #535 measured out of order. Hence a refusal rather than a note.
|
|
1332
|
+
|
|
1288
1333
|
**R11 — The `skins` array is written with `default` first and the rest in exactly
|
|
1289
1334
|
the order R10 describes.** A skin is a **name** in the JSON half of the format and
|
|
1290
1335
|
an **ordinal** in the binary half — `skins[readInt()]` for an attachment timeline,
|
|
@@ -3043,6 +3088,12 @@ stored round trips — so the emit is the editor's own order, and only what thos
|
|
|
3043
3088
|
round trips leave open is a compile error. **R10** has the rule and the three
|
|
3044
3089
|
shapes to avoid.
|
|
3045
3090
|
|
|
3091
|
+
🔢 The one order the emit cannot give is a plain non-negative integer's: a JSON
|
|
3092
|
+
object lists `5` and `10` before every other name, so a slider whose animation
|
|
3093
|
+
that moves off its editor index is refused at compile, naming the animation it
|
|
3094
|
+
would come back applying. Measured on a licensed 4.3.26 editor; R10 carries the
|
|
3095
|
+
dated record.
|
|
3096
|
+
|
|
3046
3097
|
✅ **What that order does not settle is a compile error, not a hazard.** Names a
|
|
3047
3098
|
codepoint sort and a friendlier one disagree about — `Turn` / `turn`, `turn2` /
|
|
3048
3099
|
`turn10` — are emitted in the order the editor returns for them: measured,
|
|
@@ -5739,6 +5790,7 @@ or the key's position in its own track. These are the frequent ones, verbatim:
|
|
|
5739
5790
|
| `rig constraint "X": rotateMode is "CHAINSCALE"; known: Tangent, Chain, ChainScale` | §3.5.1 — only the first letter's case is free; anything else resolves to `undefined` in the parser |
|
|
5740
5791
|
| `<file>: slot "X" has blend "ADDITIVE"; known: normal, additive, multiply, screen (only the first letter's case is free — …)` | §3.3 — write the mode in lower case, or with only its first letter upper-cased. `SkeletonJson` reads `blend` through `Utils.enumValue`, which upper-cases the first character and looks the rest up exactly, so `ADDITIVE` loaded as no mode at all (`tools/pose_oracle.ts dump` reads it as `null`), with no error. rigc used to compare the name case-insensitively and build that file green |
|
|
5741
5792
|
| `rig constraint "X": applies animation "Y", which the motion spec does not declare (it declares: …)` | §3.5.2 — fix the slider's `animation`, or add it to the motion spec |
|
|
5793
|
+
| `rig constraint "X": applies animation "Y", which the Spine file lists at index I of its animations and the Spine editor sorts to index J — so an editor import repoints this slider to "Z" …` | **R10** — a JSON object lists every array-index name (`5`, `10`) first, the editor re-sorts the animations and keeps a slider's by index (measured, R10's dated record). Rename the array-index animation names the message lists — a prefix or a leading zero (`a5`, `05`) is enough |
|
|
5742
5794
|
| `rig constraint "X": declares both a "bone" and "time"` | §3.5.2 — `bone` picks the model and `time` belongs to the other one |
|
|
5743
5795
|
| `rig constraint "X": declares "property" but no "bone"` | §3.5.2 — name the driving bone, or key `slider.<name>.time` instead |
|
|
5744
5796
|
| `rig constraint "X": drives off bone "Y" rotate with "local": false, and the driving values that reach animation "A" (0s..Ds) run from −15.000° to 15.000° … the whole part of the range below 0° is dead` | §3.5.2 — add `"local": true`, which reads the bone's own rotation signed and unwrapped, or move the range so it does not cross 0°. A world rotation is wrapped into `[0, 360]` before the slider maps it, so the negative half of the range is unreachable and pins to one frame |
|
|
@@ -5853,13 +5905,13 @@ Fix A00 and run it again.
|
|
|
5853
5905
|
|
|
5854
5906
|
| Assertion | Profile | What tripped it, and where to fix it |
|
|
5855
5907
|
| --- | --- | --- |
|
|
5856
|
-
| `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Almost everything else in the report is downstream of this one — fix it first. When it fails, every rule that reads the loaded skeleton or the loaded atlas reports **SKIP** saying so by name, so the row count stays at the full registry and the summary's *measured* figure tells you how little was actually asked. ⚠️ **Two rules run before it and can be upstream of it**: `A31_DRAW_ORDER_OFFSETS_RESOLVE`, because a bad draw-order key makes the loader spin rather than return, and `A08_REGION_NAMES_MATCH_ATTACHMENTS`, because a `path` naming no region makes it throw. The round trip is still attempted either way; when the loader refuses a path A08 has already refused, this row **defers** to A08 by name instead of restating the miss in the parser's poorer words Under `cli_core.ts build`, which links none of spine-core, it is a **SKIP** naming spine-core — the one rule of the 49 that entry does not run
|
|
5908
|
+
| `A00_ROUNDTRIP_PARSE` | both | `spine-core` could not parse the skeleton or the atlas. Almost everything else in the report is downstream of this one — fix it first. When it fails, every rule that reads the loaded skeleton or the loaded atlas reports **SKIP** saying so by name, so the row count stays at the full registry and the summary's *measured* figure tells you how little was actually asked. ⚠️ **Two rules run before it and can be upstream of it**: `A31_DRAW_ORDER_OFFSETS_RESOLVE`, because a bad draw-order key makes the loader spin rather than return, and `A08_REGION_NAMES_MATCH_ATTACHMENTS`, because a `path` naming no region makes it throw. The round trip is still attempted either way; when the loader refuses a path A08 has already refused, this row **defers** to A08 by name instead of restating the miss in the parser's poorer words Under `cli_core.ts build`, which links none of spine-core, it is a **SKIP** naming spine-core — the one rule of the 49 that entry does not run. The entry that links spine-core runs it on `build` and `validate`: installed, the same `rigc` once `@esotericsoftware/spine-core` is installed beside the package; from a clone, `bun cli.ts` (`RC28`). |
|
|
5857
5909
|
| `A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS` | both | a 4.1/4.2-shaped `ik`/`transform`/`path`/`physics`/`slider` array. rigc emits the 4.3 `constraints` array, so this normally means hand-edited JSON Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
|
|
5858
5910
|
| `A02_NO_BONE_TRANSFORM_KEY` | both | `bone "…" uses "transform", the key 4.0 and 4.1 spelled; 4.2 and 4.3 spell it "inherit"` — rename it `inherit` in the rig spec Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
|
|
5859
5911
|
| `A03_REGION_WIDTH_HEIGHT_FINITE` | both | a region loaded `NaN` or a non-positive size — the attachment has no `image` and no `width`/`height`. **SKIP** when the skeleton carries no region attachment |
|
|
5860
5912
|
| `A04_MESH_TRIANGLES_AND_ENCODING` | both | authored mesh geometry: triangle count not a multiple of 3, an index out of range, or a `vertices` length that disagrees with `uvs` (the weighted/unweighted trap) **SKIP** when the skeleton carries no mesh attachment |
|
|
5861
5913
|
| `A05_CURVE_ARRAY_LENGTH` | both | a raw `curve` with the wrong number of values, a non-finite number in one, or a curve on a timeline that cannot take one. Four numbers **per value channel**. **SKIP** when no animation carries a timeline at all. Timelines with no `curve` on any key still PASS: every timeline name is checked against the channel table whether or not a curve sits on one Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
|
|
5862
|
-
| `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk — the message names the ratio it measured on **each** axis and the header that states the same art truthfully, and §0.2 has the whole of it: a runtime maps a region as a fraction of the DECLARED size and never reads the texture's own, so such a page draws, and what it breaks is every reader that addresses the page in texels. A uniform ratio is a `size:`/`scale:` pair away from honest and the message prints it; two ratios are not, because `scale:` carries one number, and the message says so rather than offering a header that would not work. **Or** a region's rectangle is not inside the page it names — rotation honoured, so a region at `rotate: 90` or `270` occupies `height x width` of the page and a region that fits only because it is turned is inside it. The message names the region, the rectangle it occupies, the page and the page's size. That clause is **validity** and runs under both profiles: a rectangle outside its page makes `u2 > 1` and samples whatever the wrap mode returns, and `--atlas-in` already refuses the same rectangle at compile time (§0.2). Under `spine-html` also: `pma`, rotation, and two regions on one page over the same texels — a packed page must be **one part covering it exactly** (the unpacked convention) or a **tiling**, and what that message names is the pair that shares texels. **Or** the page file is not a PNG at all, and then nothing about its size is measured: the message carries the page's path and the size the atlas declares, and names what the file is by its first bytes — one of WebP, JPEG, GIF, KTX, KTX2 by its signature, or no image format rigc recognises, the bytes in hex either way — or calls it **truncated** when it begins as a PNG and runs out before its `IEND` (§0.2). That clause is validity too: nothing in rigc can read such a page back. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page's file is **not on disk** and nothing else here failed: that page's size was not measured, so the reason names the pages not read (or says none was, when every file is missing) and points at `A17`, which fails naming each missing file — one missing file is named once, by the rule whose subject it is. With another `A06` failure standing, the missing page is `A17`'s alone |
|
|
5914
|
+
| `A06_ATLAS_PAGE_SIZE_MATCHES_PNG` | both ◑ | the atlas `size:` disagrees with the PNG on disk — the message names the ratio it measured on **each** axis and the header that states the same art truthfully, and §0.2 has the whole of it: a runtime maps a region as a fraction of the DECLARED size and never reads the texture's own, so such a page draws, and what it breaks is every reader that addresses the page in texels. A uniform ratio is a `size:`/`scale:` pair away from honest and the message prints it; two ratios are not, because `scale:` carries one number, and the message says so rather than offering a header that would not work. **Or** a region's rectangle is not inside the page it names — rotation honoured, so a region at `rotate: 90` or `270` occupies `height x width` of the page and a region that fits only because it is turned is inside it. The message names the region, the rectangle it occupies, the page and the page's size. That clause is **validity** and runs under both profiles: a rectangle outside its page makes `u2 > 1` and samples whatever the wrap mode returns, and `--atlas-in` already refuses the same rectangle at compile time (§0.2). Under `spine-html` also: `pma`, rotation, and two regions on one page over the same texels — a packed page must be **one part covering it exactly** (the unpacked convention) or a **tiling**, and what that message names is the pair that shares texels. **Or** the page file is not a PNG at all, and then nothing about its size is measured: the message carries the page's path and the size the atlas declares, and names what the file is by its first bytes — one of WebP, JPEG, GIF, KTX, KTX2 by its signature, or no image format rigc recognises, the bytes in hex either way — or calls it **truncated** when it begins as a PNG and runs out before its `IEND` (§0.2). That clause is validity too: nothing in rigc can read such a page back — and its failure is the **one** line that names such a page: `A19` SKIPs over it and points here rather than naming it again, the way `A17` alone names a missing file. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page's file is **not on disk** and nothing else here failed: that page's size was not measured, so the reason names the pages not read (or says none was, when every file is missing) and points at `A17`, which fails naming each missing file — one missing file is named once, by the rule whose subject it is. With another `A06` failure standing, the missing page is `A17`'s alone |
|
|
5863
5915
|
| `A07_ATLAS_TEXT_SHAPE` | both | atlas text: a region name with stray whitespace, or a blank line splitting a page block. rigc writes the atlas, so this means a hand-edited file. Three sentences are about blank lines and name three different repairs: `line 1: the file begins with a blank line` (or `with N blank lines` for a run, one finding) is a blank before the first page name — delete it; `line N: the file ends with a blank line` (or `with N blank lines`, one finding) is a blank after the last page block's final line — delete it, since rigc ends the file on one newline; `line N: consecutive blank lines` is two blank lines side by side between the first page and the last — delete one. None of the three is `the last page block declares no region`, which means a page name and its fields with no region after them. rigc writes none of the three: under `--atlas-in` the pack's blank lines are put in rigc's shape on re-emission (§0.2), so any of them means a file rigc did not write, handed to `validate`. ⚠️ An atlas with **no page block at all** — no non-blank line — is not one of those: its subject is absent, so this reports **SKIP** naming the byte count it read, and so do the four rules below whose subject is a page ([#608](https://github.com/firejune/rigc/issues/608)). A rig whose skins need no art writes exactly that file (§3.4), and before #608 this row refused it with two findings naming a page block that was not there. What an empty atlas does **not** excuse is an attachment that wants a region out of it — that is `A08` Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
|
|
5864
5916
|
| `A08_REGION_NAMES_MATCH_ATTACHMENTS` | both | three things, and the message says which: an attachment whose `path` names **no region** of this atlas; a `path` carrying **stray whitespace**, printed quoted so you can see it; an **atlas region name** carrying stray whitespace (`A07` names that same line with its line number). The first two are read off the raw file **before** the loader is asked, so the miss is named here with the skin, the slot, the placeholder and the attachment's own name — the four things `AtlasAttachmentLoader`'s own `Region not found in atlas: <path> (attachment: <name>)` does not carry. There is no `spine-html` clause here — a placeholder is free to differ from the region its `path` names **SKIP** when no attachment names a region *and* the atlas declares none — both of its subjects at once |
|
|
5865
5917
|
| `A09_ANIMATION_DURATION_MATCHES_SPEC` | both | the loaded duration ≠ the declared one, or the two sides disagree about which animations exist (R7). Asymmetric by design: a frame of slack for an animation that ends early, and none worth the name for a key *past* the declared end, which is the same rule §4.5 states at compile time — held here against a skeleton the compiler never saw. **SKIP** when neither side has an animation at all — a static rig has no duration |
|
|
@@ -5872,7 +5924,7 @@ Fix A00 and run it again.
|
|
|
5872
5924
|
| `A16_SKELETON_VERSION_4_3` | both | the `skeleton.spine` label is not on the 4.3 line (`4.3`, `4.3.N`, `4.3.N-suffix`) Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
|
|
5873
5925
|
| `A17_ATLAS_PAGE_FILES_EXIST` | both | a page the atlas declares is not a file. Check `--images` and `--out`. This is the one rule that names a missing page file; `A06` and `A19` SKIP over the page they could not read and point here rather than naming it again. **SKIP** when the atlas declares no page — as it is for `A06`, `A19` and `A27`; see `A07` |
|
|
5874
5926
|
| `A18_DETERMINISTIC_EMIT` | both | a second compile of the same inputs differed — in `skeleton.json`, `skeleton.atlas` or the model document `skeleton.model.json`, the one named (the document's first differing line with it). That is a compiler bug, not a spec bug — report it Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
|
|
5875
|
-
| `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part cannot draw a transparent pixel, so it would paint a solid rectangle over what is behind it. **The texels decide, on both routes**; the file header is only the fast negative. A file with no alpha channel (colour type 4 or 6) and no `tRNS` chunk has nowhere to keep a clear texel and is refused without being opened, by a sentence that names its colour type — so re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. A file whose header says it **could** be transparent is opened and read until its first texel below full alpha; one clear texel passes, and none is refused in the words the packed route uses for a region, naming the file and what it can hold — `is opaque in every one of its 200x80 texels` … `its file can hold transparency — colour type 6 (truecolour + alpha) carries an alpha channel — and no texel uses it`. Saving as RGBA is not the repair: the runtime draws the texels, not the declaration. On a loose page the file is the part, so the whole decoded image is the rectangle and no atlas coordinate is read. Only the base plate may be opaque, and which image that is is decided once for both routes: the plate the build names — on a cut manifest, the part whose window is the crop — and, only when it names none, an image at least the stage's size. The rig's statement comes first because the two can disagree: a stage stated small enough for an overlay to cover would otherwise exempt that overlay. A rig spec cannot name a base plate, so a stageless rig-spec build, and `validate <dir>` on a stageless skeleton, has nothing to decide it; an opaque part there is refused with *"nothing here decides which image that is: this skeleton declares no stage size to measure one against"* and the two ways to decide it — a `skeleton` stage the plate covers, or a cut manifest (for `validate`, the specs it was built from: `--rig`, `--motion` and the `--manifest`). Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art. ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page**: a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it**, and the clause above does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so a scan would come back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappears**, the scan finding a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice.
|
|
5927
|
+
| `A19_OVERLAY_PNGS_HAVE_ALPHA` | renderer | an overlay part cannot draw a transparent pixel, so it would paint a solid rectangle over what is behind it. **The texels decide, on both routes**; the file header is only the fast negative. A file with no alpha channel (colour type 4 or 6) and no `tRNS` chunk has nowhere to keep a clear texel and is refused without being opened, by a sentence that names its colour type — so re-export it as RGBA, or as an indexed / greyscale PNG that keeps its `tRNS`. A file whose header says it **could** be transparent is opened and read until its first texel below full alpha; one clear texel passes, and none is refused in the words the packed route uses for a region, naming the file and what it can hold — `is opaque in every one of its 200x80 texels` … `its file can hold transparency — colour type 6 (truecolour + alpha) carries an alpha channel — and no texel uses it`. Saving as RGBA is not the repair: the runtime draws the texels, not the declaration. On a loose page the file is the part, so the whole decoded image is the rectangle and no atlas coordinate is read. Only the base plate may be opaque, and which image that is is decided once for both routes: the plate the build names — on a cut manifest, the part whose window is the crop — and, only when it names none, an image at least the stage's size. The rig's statement comes first because the two can disagree: a stage stated small enough for an overlay to cover would otherwise exempt that overlay. A rig spec cannot name a base plate, so a stageless rig-spec build, and `validate <dir>` on a stageless skeleton, has nothing to decide it; an opaque part there is refused with *"nothing here decides which image that is: this skeleton declares no stage size to measure one against"* and the two ways to decide it — a `skeleton` stage the plate covers, or a cut manifest (for `validate`, the specs it was built from: `--rig`, `--motion` and the `--manifest`). Indexed-with-`tRNS` — the usual output of ImageMagick, "Export as PNG-8", GIMP's indexed mode, aseprite and pngquant — **passes**: it is transparent art. On a **shared** page the question is asked per REGION over the decoded page rather than per file, because a packed page's own file all but always declares transparency — its gutter is transparent — and the file-level question would then be answered by the packing rather than by the art. ⚠️ **That scan states its verdict over the texels it READ, and never over texels that are not on the page**: a rectangle partly on its page is judged over the part that is on it, and the message carries both counts — `opaque in every one of the 77 texels of its 12x8 rectangle at -1,-1 … the other 19 of the 96 it declares are not on the page and are not measured here`. A rectangle with **no** texel on the page is reported **not measured** by name — the region, its rectangle, the page image's size, and the pointer to `A06`, which is the rule that judges a region's rectangle — and no verdict about opacity is printed at all. ⚠️ **A page whose IMAGE is not the size the atlas declares for it is the same non-measurement for every region on it**, and the clause above does not cover that case: a page rescaled after packing leaves most rectangles partly on it, at coordinates that address a different part of the picture, so a scan would come back with a confident verdict over texels nobody had located — on a two-region pack at a uniform 0.5 an opaque part's failure **disappears**, the scan finding a transparent texel 32 texels away from it. The row names the page's two sizes and points at `A06`, which judges the page grid and prints the header that repairs it (§0.2). It stays a failure rather than becoming a SKIP because a SKIP is per ASSERTION: it would delete the verdicts on every other part of the same page, and an assertion cannot be skipped and failed at once without the report counting it twice. **SKIP** when the atlas declares no page — and, rather than a PASS, when a page carrying a part was not read and nothing else here failed: the reason names the pages not read and how many of the pages carrying a part were (or says none was), and for each page points at the one rule that names its file — `A17` when the file is **not on disk**, `A06` when it is on disk and **cannot be read as PNG** (not a PNG, or truncated; `A06` fails naming what the file is on every such page, under both profiles). So one unreadable file is one red line, never two: with another `A19` failure standing, the unread page is that rule's alone and this one says nothing about it. A page holding only the base plate moves nothing here, missing or unreadable: this rule had no part on it to read. ⚠️ A page whose header reads as PNG and whose pixel data the decoder then refuses (an intact chunk walk over a broken compressed stream) is not caught by `A06`, which reads the header only; it reaches this rule's texel scan, which fails on it with the decoder's own error (`threw: cannot decode PNG …`) |
|
|
5876
5928
|
| `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. On a rig spec's `"boneIndexing": "raw"` mesh an index out of range or a run that does not decode is refused at compile first (§5.1, issue #917), so here it names a skeleton rigc did not compile. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not. Fix the rig spec's `controls`/`chain`, a `segments` generator's `bones` (its `influence` line names a bone no vertex binds), or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment |
|
|
5877
5929
|
| `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1. **SKIPs** by name on authored or linked geometry and on a `segments` mesh, whose outline is weighted by distance like the rest of it — none of them claims a pinned rim |
|
|
5878
5930
|
| `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skills/rigc/SKILL.md
CHANGED
|
@@ -44,6 +44,13 @@ Codex, Gemini CLI and Antigravity read skills from `.agents/skills/` in the
|
|
|
44
44
|
workspace and none of them reads `node_modules`; `rigc skills install` puts every
|
|
45
45
|
skill the package ships there, and `rigc skills --help` says what it refuses.
|
|
46
46
|
|
|
47
|
+
The package links no Spine runtime. `validate`, `preview` and `vote` below run
|
|
48
|
+
through `@esotericsoftware/spine-core`: install it beside the package
|
|
49
|
+
(`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs them, and
|
|
50
|
+
`build` runs the round trip as well. Without it they are refused by name, and
|
|
51
|
+
`build` writes the same files gated without the parse — `rigc --version` names the
|
|
52
|
+
entry that ran.
|
|
53
|
+
|
|
47
54
|
## The loop
|
|
48
55
|
|
|
49
56
|
1. `rigc build --rig <spec> --motion <spec> --images <dir> --out <dir>` compiles,
|
|
@@ -81,6 +81,14 @@ export function a06AtlasPageSizeMatchesPng({ fail: failed, skip }: Verdicts, { a
|
|
|
81
81
|
// of a page states; this rule prefixes the size the atlas declares, the
|
|
82
82
|
// value the file would have had to carry.
|
|
83
83
|
//
|
|
84
|
+
// 🔒 **And this is the one line that names it** (issue #1064). A file's
|
|
85
|
+
// readability is this rule's subject — it runs under both profiles and
|
|
86
|
+
// asks `pngProblem` first, so on every input where `A19` would find the
|
|
87
|
+
// same page unreadable, this FAIL is already printed. `A19` therefore
|
|
88
|
+
// SKIPs naming the page and pointing here when it failed nothing else,
|
|
89
|
+
// and says nothing about the page when it did: one unreadable file, one
|
|
90
|
+
// line, the way #1055 made one missing file A17's alone.
|
|
91
|
+
//
|
|
84
92
|
// ⚠️ No size is read off a file rigc cannot decode, deliberately. A WebP
|
|
85
93
|
// header carries its dimensions in a fixed field, and reading them would
|
|
86
94
|
// print a number no oracle in this tree has checked (rigc links no WebP
|
|
@@ -122,6 +122,7 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
|
|
|
122
122
|
// moves nothing here (the size it was not measured at is `A06`'s to say).
|
|
123
123
|
let pagesWithParts = 0;
|
|
124
124
|
const unread: string[] = [];
|
|
125
|
+
const unreadable: string[] = [];
|
|
125
126
|
for (const page of atlas.pages) {
|
|
126
127
|
const abs = resolve(input.atlasDir, page.name);
|
|
127
128
|
const on = sharedPages.get(page.name) ?? [];
|
|
@@ -131,28 +132,42 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
|
|
|
131
132
|
if (carriesPart) unread.push(page.name); // A17 names the file; this body says only that it did not read it
|
|
132
133
|
continue;
|
|
133
134
|
}
|
|
134
|
-
// 🔒 **A page that is
|
|
135
|
-
//
|
|
136
|
-
//
|
|
137
|
-
//
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
135
|
+
// 🔒 **A page that is on disk and cannot be read as a PNG is named once,
|
|
136
|
+
// by `A06`, and this body only says it did not read it** (issue #1064,
|
|
137
|
+
// the other half of #1055's rule). Until this it FAILed here with a
|
|
138
|
+
// second sentence about the same file (#732's `not measured … belongs to
|
|
139
|
+
// A06`), so one unreadable page was two red lines: measured on the tree's
|
|
140
|
+
// 19 recipes with the first page replaced by text bytes, by a PNG cut
|
|
141
|
+
// inside its IDAT and by one with a wrong signature byte, A06 FAILed
|
|
142
|
+
// naming the file on 19 of 19 under both profiles and this rule added its
|
|
143
|
+
// FAIL on the 12 whose first page carries a part (`spine-html`).
|
|
144
|
+
//
|
|
145
|
+
// ⭐ Who names it is read off the two bodies, not chosen. Both ask
|
|
146
|
+
// `readPngHeader` (`pngProblem`, the one reader of a page's identity) the
|
|
147
|
+
// same question about the same file, A06 runs under both profiles and this
|
|
148
|
+
// rule under `spine-html` alone, and A06 FAILs on every page for which it
|
|
149
|
+
// answers with a problem — so on every input that reaches this branch, A06
|
|
150
|
+
// has already named the file and the report is red. A FAIL here would be
|
|
151
|
+
// the second naming and a PASS a certificate over alpha nobody read, so
|
|
152
|
+
// the page joins `#1055`'s unread list, with its own owner beside it.
|
|
153
|
+
//
|
|
154
|
+
// ⚠️ #732 chose a FAIL because `skip()` is per assertion and would delete
|
|
155
|
+
// the verdicts on every other page. Under #1055's rule that argument no
|
|
156
|
+
// longer forces it: the SKIP below is printed only when this body failed
|
|
157
|
+
// nothing else, so the verdicts it would replace are all passes, and its
|
|
158
|
+
// reason says how many pages they covered. With another failure standing,
|
|
159
|
+
// the failures stand and the unreadable page is A06's alone. A page
|
|
160
|
+
// holding nothing but the base plate had nothing for this rule to read
|
|
161
|
+
// either way and moves nothing, exactly as a missing one does.
|
|
162
|
+
//
|
|
163
|
+
// ⚠️ Only what `pngProblem` refuses comes here. A page whose header it
|
|
164
|
+
// accepts and whose texels the decoder then refuses (a broken zlib stream
|
|
165
|
+
// in an intact chunk walk) reaches `readPlate` below, which A06 never
|
|
166
|
+
// calls — this rule is the one reader that finds that file unreadable,
|
|
167
|
+
// and its line is unchanged by this.
|
|
141
168
|
const header = readPngHeader(abs);
|
|
142
169
|
if (header.problem !== null) {
|
|
143
|
-
|
|
144
|
-
const parts = shared ? on.filter((region) => !baseRegions.has(region.name)).map((region) => region.name) : [];
|
|
145
|
-
if (shared ? parts.length === 0 : basePages.has(page.name)) continue;
|
|
146
|
-
const subject = shared
|
|
147
|
-
? `the ${parts.length} part(s) on shared page "${page.name}" (${parts.map((name) => JSON.stringify(name)).join(', ')}) are`
|
|
148
|
-
: `part image "${page.name}" is`;
|
|
149
|
-
fail(
|
|
150
|
-
'A19_OVERLAY_PNGS_HAVE_ALPHA',
|
|
151
|
-
`${subject} not measured: this rule reads a page's alpha out of its PNG, and the file cannot be read as ` +
|
|
152
|
-
'one, so it states nothing about whether any of them can draw a transparent pixel. What the file is ' +
|
|
153
|
-
"belongs to A06_ATLAS_PAGE_SIZE_MATCHES_PNG, which names it. This is renderer policy, and it belongs to " +
|
|
154
|
-
'--profile spine-html: the default --profile spine does not run this check.',
|
|
155
|
-
);
|
|
170
|
+
if (carriesPart) unreadable.push(page.name); // A06 names the file; this body says only that it did not read it
|
|
156
171
|
continue;
|
|
157
172
|
}
|
|
158
173
|
if (on.length > 1) {
|
|
@@ -323,15 +338,49 @@ export function a19OverlayPngsHaveAlpha({ fail: failed, skip }: Verdicts, { atla
|
|
|
323
338
|
'spine does not run this check.',
|
|
324
339
|
);
|
|
325
340
|
}
|
|
326
|
-
if (failures > 0 || unread.length === 0) return;
|
|
327
|
-
skip(
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
341
|
+
if (failures > 0 || unread.length + unreadable.length === 0) return;
|
|
342
|
+
skip('A19_OVERLAY_PNGS_HAVE_ALPHA', unreadAlpha(pagesWithParts, unread, unreadable));
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* The SKIP's reason over the pages carrying a part that this rule did not
|
|
347
|
+
* read: `missing` (not on disk, which A17 names — issue #1055) and
|
|
348
|
+
* `unreadable` (on disk and not a PNG rigc can read, which A06 names — issue
|
|
349
|
+
* #1064). With only missing pages it is #1055's sentence to the byte; each
|
|
350
|
+
* group names its own owner, so one file is pointed at the one rule that
|
|
351
|
+
* names it.
|
|
352
|
+
*/
|
|
353
|
+
function unreadAlpha(pagesWithParts: number, missing: readonly string[], unreadable: readonly string[]): string {
|
|
354
|
+
const A17 = 'A17_ATLAS_PAGE_FILES_EXIST';
|
|
355
|
+
const A06 = 'A06_ATLAS_PAGE_SIZE_MATCHES_PNG';
|
|
356
|
+
const names = (pages: readonly string[]): string => pages.map((name) => JSON.stringify(name)).join(', ');
|
|
357
|
+
const total = missing.length + unreadable.length;
|
|
358
|
+
if (total === pagesWithParts) {
|
|
359
|
+
if (unreadable.length === 0) {
|
|
360
|
+
return (
|
|
361
|
+
`no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part is on disk, which ` +
|
|
362
|
+
`${A17} names file by file, and that is not a pass while every part is unread`
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
if (missing.length === 0) {
|
|
366
|
+
return (
|
|
367
|
+
`no part's alpha was read: none of the ${pagesWithParts} page file(s) carrying a part can be read as PNG, ` +
|
|
368
|
+
`which ${A06} names file by file, and that is not a pass while every part is unread`
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
return (
|
|
372
|
+
`no part's alpha was read: of the ${pagesWithParts} page file(s) carrying a part, ${missing.length} are not ` +
|
|
373
|
+
`on disk, which ${A17} names file by file, and ${unreadable.length} cannot be read as PNG, which ${A06} ` +
|
|
374
|
+
'names file by file, and that is not a pass while every part is unread'
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
const because = [
|
|
378
|
+
...(missing.length > 0 ? [`${names(missing)} — because the file is not on disk, which ${A17} names`] : []),
|
|
379
|
+
...(unreadable.length > 0 ? [`${names(unreadable)} — because the file cannot be read as PNG, which ${A06} names`] : []),
|
|
380
|
+
].join(', and — ');
|
|
381
|
+
return (
|
|
382
|
+
`${total} of the ${pagesWithParts} page(s) carrying a part were not read — ${because}. Every part on the ` +
|
|
383
|
+
`${pagesWithParts - total} page(s) that were read can draw a transparent pixel, and that is not a pass while ` +
|
|
384
|
+
"a part's alpha is unread"
|
|
336
385
|
);
|
|
337
386
|
}
|
|
@@ -55,7 +55,9 @@ interface EmittedTexts {
|
|
|
55
55
|
|
|
56
56
|
/** A00's SKIP here: what did not run, and where it does. */
|
|
57
57
|
export const SKIP_NO_ROUND_TRIP =
|
|
58
|
-
"spine-core's parser is the subject of this rule and this entry links none of it —
|
|
58
|
+
"spine-core's parser is the subject of this rule and this entry links none of it — the entry that links it runs the round trip on build and validate " +
|
|
59
|
+
'(installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, `bun cli.ts`), ' +
|
|
60
|
+
'and the selftest and CI run it on every corpus build';
|
|
59
61
|
|
|
60
62
|
/** The code this file SKIPs rather than restates. */
|
|
61
63
|
const A00 = 'A00_ROUNDTRIP_PARSE';
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -1573,7 +1573,8 @@ const MODEL_AND_TEXT_GATE: BuildGate = {
|
|
|
1573
1573
|
const notRun = text.skipped.filter((x) => x.assertion === 'A00_ROUNDTRIP_PARSE').map((x) => x.assertion);
|
|
1574
1574
|
console.log(
|
|
1575
1575
|
` .. here: ${ran(model)} rule(s) on the model side over the document, ${ran(text) - notRun.length} of the round trip's own restated over the emitted text; ` +
|
|
1576
|
-
`not run: ${notRun.join(', ') || 'none'} — spine-core's parse, which only
|
|
1576
|
+
`not run: ${notRun.join(', ') || 'none'} — spine-core's parse, which only the entry that links spine-core runs, on build and validate: ` +
|
|
1577
|
+
`installed, the same \`rigc\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts\``,
|
|
1577
1578
|
);
|
|
1578
1579
|
return report.failures.length;
|
|
1579
1580
|
},
|
package/src/cli/shared.ts
CHANGED
|
@@ -1238,22 +1238,25 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1238
1238
|
for: 'the gate round-trips every build through it before anything is written',
|
|
1239
1239
|
core: {
|
|
1240
1240
|
usage: [
|
|
1241
|
-
'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] (the same files
|
|
1241
|
+
'rigc build --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--profile spine|spine-html] [--copy-images] (the same files the round-tripped build writes; gated without spine-core — see build --help)',
|
|
1242
1242
|
`rigc build … --pack [--page-size ${DEFAULT_PAGE_SIZE}] [--padding ${DEFAULT_PADDING}] [--page-edges pot|free] (parts onto shared pages, written into --out)`,
|
|
1243
1243
|
'rigc build … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made)',
|
|
1244
1244
|
'rigc build --cut <name> --cuts <cuts.json>',
|
|
1245
1245
|
],
|
|
1246
1246
|
notes: [
|
|
1247
|
-
'this entry\'s build writes what
|
|
1248
|
-
'skeleton.model.json and the pages --pack or --copy-images put in
|
|
1249
|
-
'body, and only when no assertion fails. Its gate is not the round
|
|
1250
|
-
'spine-core, which this entry links none of. What runs instead: the model
|
|
1251
|
-
'the document (every assertion moved off the round trip), and the round
|
|
1252
|
-
'rules restated over the text the emitter wrote — A01, A02, A05, A07, A16,
|
|
1253
|
-
'and A18 over a second, independent compile\'s skeleton, atlas and document.
|
|
1254
|
-
'not run is A00_ROUNDTRIP_PARSE, spine-core\'s parse: it reports SKIP naming
|
|
1255
|
-
'
|
|
1256
|
-
'
|
|
1247
|
+
'this entry\'s build writes what the entry that links spine-core writes — skeleton.json,',
|
|
1248
|
+
'skeleton.atlas, skeleton.model.json and the pages --pack or --copy-images put in',
|
|
1249
|
+
'--out — by the same body, and only when no assertion fails. Its gate is not the round',
|
|
1250
|
+
'trip through spine-core, which this entry links none of. What runs instead: the model',
|
|
1251
|
+
'side over the document (every assertion moved off the round trip), and the round',
|
|
1252
|
+
'trip\'s own rules restated over the text the emitter wrote — A01, A02, A05, A07, A16,',
|
|
1253
|
+
'A31, A35, and A18 over a second, independent compile\'s skeleton, atlas and document.',
|
|
1254
|
+
'What does not run is A00_ROUNDTRIP_PARSE, spine-core\'s parse: it reports SKIP naming',
|
|
1255
|
+
'spine-core. The report\'s last line says which ran here and which did not. A00 runs',
|
|
1256
|
+
'in build and validate on the entry that links spine-core: installed, the same `rigc`',
|
|
1257
|
+
'runs that entry once @esotericsoftware/spine-core is installed beside the package',
|
|
1258
|
+
'(`rigc --version` names the entry that ran); from a source checkout, it is',
|
|
1259
|
+
'`bun cli.ts`.',
|
|
1257
1260
|
],
|
|
1258
1261
|
},
|
|
1259
1262
|
},
|
|
@@ -1737,7 +1740,8 @@ export function usageText(docs: readonly CommandDoc[], checkout: string, rules:
|
|
|
1737
1740
|
'',
|
|
1738
1741
|
checkout === 'cli.ts'
|
|
1739
1742
|
? '(from a source checkout: `bun cli.ts <command>` is the same as `rigc <command>`)'
|
|
1740
|
-
: `(
|
|
1743
|
+
: `(installed: \`rigc\` runs this entry where @esotericsoftware/spine-core is not installed beside the package, and every command once it is; ` +
|
|
1744
|
+
`from a source checkout: \`bun ${checkout} <command>\` runs the commands below, which link nothing of spine-core; \`bun cli.ts <command>\` runs every command)`,
|
|
1741
1745
|
'',
|
|
1742
1746
|
'usage:',
|
|
1743
1747
|
...docs.flatMap((c) => c.usage.map((u) => ` ${u}`)),
|
|
@@ -2253,7 +2257,8 @@ function commandNeedsRuntime(doc: CommandDoc, runs: readonly string[]): SpineRun
|
|
|
2253
2257
|
const needs = doc.runtime === false ? 'nothing' : doc.runtime.for;
|
|
2254
2258
|
return new SpineRuntimeError(
|
|
2255
2259
|
`\`${doc.name}\` runs through spine-core (${needs}), and the runtime could not be used: ${SPINE_SIDE_ABSENT}. ` +
|
|
2256
|
-
`The commands this entry runs are ${runs.join(', ')};
|
|
2260
|
+
`The commands this entry runs are ${runs.join(', ')}; the entry that links spine-core runs \`${doc.name}\` — ` +
|
|
2261
|
+
`installed, \`rigc ${doc.name}\` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, \`bun cli.ts ${doc.name}\``,
|
|
2257
2262
|
);
|
|
2258
2263
|
}
|
|
2259
2264
|
|
package/src/compile.ts
CHANGED
|
@@ -338,6 +338,54 @@ export function editorAnimationOrder(names: readonly string[]): string[] {
|
|
|
338
338
|
return keyedOrder(editorNamesInOrder(names, 'animations'));
|
|
339
339
|
}
|
|
340
340
|
|
|
341
|
+
/** Where a slider's animation stands in the file and after the editor's re-sort, when the two differ (`sliderAnimationTheEditorRepoints`). */
|
|
342
|
+
interface RepointedSlider {
|
|
343
|
+
/** The animation's index in the order the file keys (`editorAnimationOrder`). */
|
|
344
|
+
fileIndex: number;
|
|
345
|
+
/** Its index in the comparator's order, which the editor re-sorts the object into. */
|
|
346
|
+
editorIndex: number;
|
|
347
|
+
/** The animation the editor puts at `fileIndex` — the one the slider comes back naming. */
|
|
348
|
+
becomes: string;
|
|
349
|
+
/** The array-index names the file lists ahead of the comparator's order. */
|
|
350
|
+
indexNames: string[];
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Whether an editor import moves a slider off `animation` — `null` when it does
|
|
355
|
+
* not (issue #1040).
|
|
356
|
+
*
|
|
357
|
+
* 🔁 **Measured, not inferred.** The editor holds a slider's animation by its
|
|
358
|
+
* INDEX in the text's order and re-sorts the `animations` object into its
|
|
359
|
+
* comparator's order (#535). Since #1034 the file's order is the comparator's
|
|
360
|
+
* with every array-index name (`5`, `10`) first, because a JSON object lists
|
|
361
|
+
* those keys first however it was filled. On 2026-10-02 a build keying
|
|
362
|
+
* `5, 10, -a, 01, 2b` went through Spine 4.3.26 and came back
|
|
363
|
+
* `-a, 01, 2b, 5, 10`, with `yaw -> "5"` returned as `yaw -> "-a"` (index 0
|
|
364
|
+
* before and after) and `tilt -> "-a"` as `tilt -> "2b"` (index 2). So the
|
|
365
|
+
* slider moves exactly when its animation's index differs between the two
|
|
366
|
+
* orders, and only then: an array-index name elsewhere in the list repoints
|
|
367
|
+
* nothing, and is not refused.
|
|
368
|
+
*
|
|
369
|
+
* A name set the comparator cannot order is R10's refusal, raised when the
|
|
370
|
+
* animations are emitted; this check has no order to read there and says
|
|
371
|
+
* nothing rather than raising it a second time in other words.
|
|
372
|
+
*/
|
|
373
|
+
function sliderAnimationTheEditorRepoints(animation: string, names: readonly string[]): RepointedSlider | null {
|
|
374
|
+
let sorted: string[];
|
|
375
|
+
try {
|
|
376
|
+
sorted = editorNamesInOrder(names, 'animations');
|
|
377
|
+
} catch (err) {
|
|
378
|
+
if (err instanceof CompileError) return null;
|
|
379
|
+
throw err;
|
|
380
|
+
}
|
|
381
|
+
const file = keyedOrder(sorted);
|
|
382
|
+
const fileIndex = file.indexOf(animation);
|
|
383
|
+
if (sorted[fileIndex] === animation) return null;
|
|
384
|
+
// An array-index name is one an object lists ahead of a key it was given first — the language's rule, not a restatement of it.
|
|
385
|
+
const indexNames = file.filter((name) => keyedOrder(['\u0000', name])[0] === name);
|
|
386
|
+
return { fileIndex, editorIndex: sorted.indexOf(animation), becomes: sorted[fileIndex], indexNames };
|
|
387
|
+
}
|
|
388
|
+
|
|
341
389
|
/**
|
|
342
390
|
* The order the editor writes a list of skin or animation names in, refusing by
|
|
343
391
|
* name every pair the five round trips leave open.
|
|
@@ -6785,6 +6833,21 @@ function buildRigConstraint(spec: RigConstraintInput, ctx: ConstraintContext): M
|
|
|
6785
6833
|
(known.length ? ` (it declares: ${known.join(', ')})` : ' (it declares none at all)'),
|
|
6786
6834
|
);
|
|
6787
6835
|
}
|
|
6836
|
+
// 🔁 The editor keeps a slider's animation by index and re-sorts the object
|
|
6837
|
+
// the file lists array-index names first in (issue #1040, measured).
|
|
6838
|
+
const repointed = sliderAnimationTheEditorRepoints(animation, [...ctx.animationNames]);
|
|
6839
|
+
if (repointed !== null) {
|
|
6840
|
+
const spelled = repointed.indexNames.map((name) => `"${name}"`).join(', ');
|
|
6841
|
+
throw new CompileError(
|
|
6842
|
+
`${where}: applies animation "${animation}", which the Spine file lists at index ${repointed.fileIndex} of ` +
|
|
6843
|
+
`its animations and the Spine editor sorts to index ${repointed.editorIndex} — so an editor import ` +
|
|
6844
|
+
`repoints this slider to "${repointed.becomes}", in a file that still parses and gates green. A JSON object ` +
|
|
6845
|
+
`lists every array-index key (${spelled} here) before the rest, the editor re-sorts the animations into its ` +
|
|
6846
|
+
'own order on import, and a slider holds its animation by index (measured on Spine 4.3.26; AUTHORING R10). ' +
|
|
6847
|
+
`Rename ${spelled} so no animation name is a plain non-negative integer — a prefix or a leading zero ` +
|
|
6848
|
+
`("a${repointed.indexNames[0]}", "0${repointed.indexNames[0]}") puts the file's order and the editor's in step.`,
|
|
6849
|
+
);
|
|
6850
|
+
}
|
|
6788
6851
|
out.animation = animation;
|
|
6789
6852
|
for (const field of ['additive', 'loop'] as const) {
|
|
6790
6853
|
if (spec[field] !== undefined) out[field] = spec[field];
|