spine-rigc 2.14.1 → 2.15.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 +7 -1
- package/docs/AUTHORING.md +12 -3
- package/package.json +1 -1
- package/src/assertions/bodies/a47.ts +68 -1
- package/src/assertions/facts/constraints.ts +13 -1
- package/src/assertions/model/constraints.ts +8 -2
- package/src/mesh.ts +58 -18
- package/src/rig.ts +22 -1
- package/src/validate.ts +3 -1
- package/tools/editor_roundtrip.ts +117 -8
package/README.md
CHANGED
|
@@ -688,7 +688,13 @@ bun tools/editor_roundtrip.ts --build build/ --editor /Applications/Spine.app/Co
|
|
|
688
688
|
Of the editor's `--version` it records one line, the `Spine <x.y.z> …` line that
|
|
689
689
|
carries the version, and nothing else that command prints — the rest of it
|
|
690
690
|
names the licence holder — or `editor version: not found in --version output`
|
|
691
|
-
where no such line is there.
|
|
691
|
+
where no such line is there. That call carries the same `-u` as the import and
|
|
692
|
+
the export, so a trip pinned with `--editor-version <v>` names the editor it
|
|
693
|
+
pinned and says so on the line (`(read from --version under -u <v>, …)`); an
|
|
694
|
+
unpinned trip names the launcher's default. Once the export exists the version
|
|
695
|
+
is held against the export's own `skeleton.spine`, and a disagreement is a FAIL
|
|
696
|
+
naming both values — the line would otherwise name an editor the trip did not
|
|
697
|
+
run on. It prints the import and export exit codes, the
|
|
692
698
|
validator's verdict on the export, every `diff` measure that moved, `check`'s mean MAE and worst drift per
|
|
693
699
|
animation **for each skin the build and the export both declare** — one
|
|
694
700
|
render-and-check block per skin, with a per-skin roll-up under them, because a
|
package/docs/AUTHORING.md
CHANGED
|
@@ -2617,8 +2617,8 @@ mesh rather than a rim prefix, and `A28_RIBBON_ROWS_SHARE_WEIGHTS` **SKIPs** wit
|
|
|
2617
2617
|
| --- | --- |
|
|
2618
2618
|
| every pixel opaque | `every pixel of the 96x64 part reaches alpha 1, so its silhouette IS the part window and a contour mesh of it is a region attachment with extra vertices` |
|
|
2619
2619
|
| two or more islands | `the art is 2 separate islands and one outline can only enclose the largest (529 of 989 px, 53.49%)` — one outline encloses one region; give each island its own slot |
|
|
2620
|
-
| a **hole** (a donut) | **accepted, and the hole is inside the mesh.** Ear clipping has no bridging step, so the outline is the art's *outer* boundary; those pixels draw nothing (their alpha is still 0) and the extra triangles are the whole cost. `build` and `explain` print the hole in pixels, because `coverage` and `overshoot` are both measured against the FILLED silhouette and neither of them moves when a part gains one |
|
|
2621
|
-
| a **diagonal pinch** — two parts of the art meeting at one pixel corner | `the alpha silhouette pinches to a single point at pixel corner (2,2) … one outline cannot pass through one point twice` |
|
|
2620
|
+
| a **hole** (a donut) | **accepted, and the hole is inside the mesh.** Ear clipping has no bridging step, so the outline is the art's *outer* boundary; those pixels draw nothing (their alpha is still 0) and the extra triangles are the whole cost. A hole is a transparent pixel that no path of transparent pixels — stepping through edges *or corners* — reaches from the part's border: the island is 4-connected, so a transparent pixel that meets the outside only at a corner is outside, not a hole. `build` and `explain` print the hole in pixels, because `coverage` and `overshoot` are both measured against the FILLED silhouette and neither of them moves when a part gains one |
|
|
2621
|
+
| a **diagonal pinch** — two parts of the art meeting at one pixel corner, with the outside on the other diagonal | `the alpha silhouette pinches to a single point at pixel corner (2,2) … one outline cannot pass through one point twice. Move the alpha threshold so the pinch opens (raise it) or closes (lower it), or author the geometry as weights` — the corner is scanned for on the **filled** silhouette, the one the outline is traced around. A corner where two holes meet, or a hole and the art, is inside that silhouette and the outline never reaches it, so it is **not** a pinch: a 4x4 block with (1,1) and (2,2) clear traces as the square, enclosing 2px of hole. A 3x3 ring with its centre and (2,2) clear is refused at (2,2), because the centre meets the outside there |
|
|
2622
2622
|
| a neck narrower than `margin` | `the outline crosses itself: edge 0 meets edge 3 after a margin of 3px was pushed out of a silhouette narrower than that` |
|
|
2623
2623
|
| more outline than `maxVertices` | `the silhouette simplified to 15 vertices at tolerance 1.5, past the 4 this mesh allows` — refused, never silently decimated |
|
|
2624
2624
|
| a `margin` too small for the `tolerance` | `the mesh covers 91.81% of the art (2936 of 3198 px), under the 99.5% a contour mesh guarantees` |
|
|
@@ -3394,6 +3394,13 @@ carrying here:
|
|
|
3394
3394
|
raw JSON the parser throws.
|
|
3395
3395
|
- Each transform mix is read **only if the matching `to` property was declared**, so
|
|
3396
3396
|
a `mixRotate` without a `rotate` entry is dead data.
|
|
3397
|
+
- An **ik** constraint's `bones` is **one bone, or two where the second's parent is the
|
|
3398
|
+
first**. The runtime's update applies one bone or two and nothing else, so an ik over
|
|
3399
|
+
three loads clean and moves nothing; and the two-bone solve places the second bone
|
|
3400
|
+
through the first's matrix from the second's own local offset, so a bone standing
|
|
3401
|
+
between the pair is left out of the triangle it solves and the tip misses the target
|
|
3402
|
+
in every frame. rigc refuses both by name (§5.1), and `A47` refuses them in a file a
|
|
3403
|
+
rig spec did not write (§5.2). A chain longer than two is several constraints.
|
|
3397
3404
|
- A physics constraint's five components all default to 0, so one that names none of
|
|
3398
3405
|
them parses cleanly and does nothing at all. rigc refuses it up front, and `A23`
|
|
3399
3406
|
catches it from the other side.
|
|
@@ -6132,6 +6139,8 @@ or the key's position in its own track. These are the frequent ones, verbatim:
|
|
|
6132
6139
|
| `<file>: <where> is Infinity; a number in this file is finite at float32 precision, at most ±3.4028234663852886e+38, because the skeleton is written as float32 — …` (also `is -Infinity`, `is 1e+308`, and `is NaN` from a library caller) | §3 — state the number you meant, inside float32's range. One walk over the whole file, run right after the key scan and before any other rig-spec check, so a range rule never prints `NaN`. `<where>` is every number in the rig spec — a bone (`bone "hip" x`), a slot, a skin's attachment down to its arrays (`skin "default" slot "s" attachment "a" weights[3][1].x`), a constraint (`constraint "aim" mixRotate`), an event, `skeleton`, `invariants.meshTriangles` — and the dotted path in the cut manifest (`crop.h`) and in the motion spec (`` `animations.idle.tracks[0].keys[1].v[0]` ``, after the motion spec's own field checks, which keep their sentences). ⚠️ It sees numbers only: `"x": "NaN"` is a string, and the row above refuses it as one |
|
|
6133
6140
|
| `two bones are called "X"` | bone names are the join key; rename one |
|
|
6134
6141
|
| `two ik constraints are called "X" — a constraint resolves by name AND type (\`SkeletonData.findConstraint\`), so names are unique PER KIND: an ik and a transform constraint may share one, two of a kind may not` | §3.5 — rename one of the two. The kind in the sentence is the pair's own, so `two transform constraints are called "X"` is the same refusal on another kind; a name shared **across** kinds is not this error |
|
|
6142
|
+
| `ik constraint "X" names 3 bones ("a", "b", "c"); the solver applies one or two, so a constraint over 3 moves nothing — name one bone, or two where the second is the first's child` | §3.5 — the constraint's `bones` is what changes: one bone, or a parent and its child. The runtime's ik update has a case for one bone and one for two, so a longer list loads, sits in the update cache and applies nothing — [measured] every bone posed exactly where the same rig with no constraint has it. A longer chain is several constraints, each over one bone or a pair |
|
|
6143
|
+
| `ik constraint "X": "B" is not a child of "A" ("C" stands between), so the two-bone solve is not of the chain drawn — name "B"'s own parent as the first bone, or make "B" a child of "A"` (also `("A" is not above it; its parent is "P")`, `("B" has no parent)`) | §3.5 — the constraint's `bones` is what changes, or `B`'s `parent`. The two-bone solve places `B` through `A`'s matrix from `B`'s own local offset, so what stands between them is left out of the triangle it solves — [measured] a bone between them at `B`'s origin left the tip up to 2.60 off the target, and one at `A`'s origin landed only while it held its identity |
|
|
6135
6144
|
| `physics constraint "X" is declared in both the rig spec and the motion spec's physics table` | §4.6 — the rig spec declares a physics constraint's structure and the motion spec's `physics` table declares one outright; pick the file it belongs in. Per kind, like every other constraint name: an `ik` "X" in the rig spec beside a `physics` "X" here is two constraints and is not this error |
|
|
6136
6145
|
| `skin "S" lists "X" under "transform", but the rig declares it as a "ik" constraint — a skin looks its constraints up by name AND type, so this one is a miss and the loader throws` | §3.4.1 — move the name to the list named after the constraint's own kind. A name the rig declares under **no** kind is the other miss and says so (`skin "S" activates ik constraint "X", which this rig does not declare`). ⚠️ A name listed by **several skins** is not an error: the lists are per-skin sets and a bone or constraint in two of them is active under either |
|
|
6137
6146
|
| `slot "X" names bone "Y", which this rig does not declare` | add the bone, or fix the slot's `bone` |
|
|
@@ -6409,7 +6418,7 @@ Fix A00 and run it again.
|
|
|
6409
6418
|
| `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | both | a **linked mesh** (§3.4) — `type: "linkedmesh"`, or a `type: "mesh"` carrying `source` — that also states `uvs`, `triangles`, `vertices`, `hull` or `edges`. The parser returns from the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so those keys are read by **nothing at all** and `setSourceMesh` fills the attachment with the source's arrays instead: the file says one mesh and every runtime draws another, in silence. The detail names the attachment by skin, slot and placeholder, every key it states, the `source` and where the parser looks for it — the two defaults spelled out, because an omitted `skin` is the **default** skin rather than the one the link is written in — and the shape the keys describe beside the shape the attachment loaded. ⚠️ **`width`/`height` are not part of this.** `setSourceMesh` overwrites both with the source's, so they are as dead at runtime — but the parser reads them (`:569-570`), the format carries them on a link and rigc emits them, so refusing them would refuse every link rigc writes (§3.4). `compile.ts` refuses the same shape outright in a rig rigc builds (§5.1); this is that fact held against a skeleton it did not write, and `ingest` reports it as `ATTACHMENT_LINK_GEOMETRY` ([INGEST §2.0](INGEST.md)). **SKIP** when no attachment in the skeleton takes its geometry from another — which is almost every skeleton, so a pass here means a link was read |
|
|
6410
6419
|
| `A45_SEPARABLE_COLOR_TIMELINES_OWN_THEIR_CHANNELS_AND_POSE_AS_WRITTEN` | both | an `rgb` or `alpha` timeline (§4.4) the runtime does not hold as the file states it, in one of two shapes that both parse in silence. **A channel keyed twice**: another colour timeline of the same slot in the same animation poses a channel this one poses — `rgba` beside `alpha` is the shape a converter leaves when it writes a separable `rgb` back as `rgba` next to the `alpha` it kept. Every colour timeline poses its channels at every time, the setup value before its first key included, so the one the file states later overwrites the other everywhere; the detail names both timelines, the channel, and which one survives. **A key not posed as written**: the animation is stepped to each key's own time — at the key **as the runtime stores it**: spine-core keeps key times as 32-bit floats, so a key at `0.2` is posed at `0.20000000298…`, the later of the two, and not one float step before it, where a first key still shows the setup value and a stepped key the one before, and the posed r g b (for `rgb`, against the hex, to half a quantisation step) or alpha (for `alpha`, against `value`, whose absence the parser reads as 0) is compared — a colour that is not six hex digits loads as NaN, and a key whose time another key repeats is read by nothing. ⚠️ An `rgb` alone written as an `rgba` holding the setup alpha is **not** caught and cannot be from the file: it is a correct `rgba`, and the difference shows only under another track that moves the alpha. The loaded timeline class and the channels a separable timeline leaves alone are not measured here, because against the linked parser neither can come out wrong. The channel table is `SLOT_COLOR_CHANNELS` in `src/timelines.ts`, shared with the compiler's refusal and held to the runtime's own property ids. **SKIP** when no animation keys an `rgb` or `alpha` — there is then no separable slot colour to read back |
|
|
6411
6420
|
| `A46_SEQUENCE_ATTACHMENTS_SHOW_THE_FRAME_THE_FILE_STATES` | both | a **numbered series** (§3.4.3, §4.13) that the runtime does not show as the file states it. Every shape below loads without a word, measured on spine-core 4.3.13. **The block**: a `sequence` with no `count` (`readSequence` reads 0, and the attachment holds no region) or a `setup` at or past `count` (`Sequence.resolveIndex` clamps it to the last frame). **The keys**: a `mode` outside the seven — `hold`, `once`, `loop`, `pingpong`, `onceReverse`, `loopReverse`, `pingpongReverse` — loads as `hold`; an `index` that is fractional (`index << 4` truncates it) or past the end (clamped); an advancing mode at an effective delay of 0 (the parser carries a key's `delay` from the key before; `(time - keyTime) / 0` is Infinity and `Infinity \| 0` is 0, so it never advances); a timeline on an attachment that carries no block (the parser gives every region a one-region series, so every mode shows it). **The pose**: every key is stepped to mid-frame sample times — enough to wrap every mode, and a `hold` key to its own time as the runtime stores it, a 32-bit float — and the region the slot shows is held to the frame the file's own statement gives, the arithmetic of `SequenceTimeline.applyToSlot` and the names of `Sequence.getPath` transcribed rather than read off the loaded timeline, so the check is not the runtime agreeing with itself. Before the first key the frame is `setup`. ⚠️ A sample where the slot shows another attachment is not compared, because the runtime writes nothing there; a timeline with no comparable sample is counted in `stats.sequenceSamplesUnshown`. `compile.ts` refuses every one of these shapes in a spec (§5.1); this is them held against a skeleton it did not write. **SKIP** when no attachment carries a `sequence` block and no animation keys a `sequence` timeline |
|
|
6412
|
-
| `A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | an ik constraint resting at `mix` 0 that no animation keys **away from 0** (§4.9, §4.12). `IkConstraint.update` returns on `mix === 0`, so it sits in the update cache and moves nothing. The keys are read the way `A23`/`A36`/`A37` read theirs — every value the loaded timeline poses on its `mix` channel, Bezier samples included — so a timeline keying 0 only is no rescue: [measured] it poses every bone exactly where the same rig with no constraint does. Live is the runtime's `!== 0`, so a negative mix is not refused. `ik constraint "C" has mix 0 at setup and none of the 1 animation keys its mix above 0; update() returns on mix 0, so "upper" never reaches for "goal" — rest it above 0, or key its mix above 0 in an animation, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "ik", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused: `ik constraint "C" is declared in the rig spec as invariants.consumerDrivenMix, and the file already switches it on — … — so the declaration exempts nothing; drop the entry`. **SKIP** when the skeleton declares no ik constraint, and **SKIP by name** when every ik constraint it declares is declared consumer-driven: `every ik constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the rule measures that one and the stats line names the declared (`ikConsumerDriven`) |
|
|
6421
|
+
| `A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | an ik constraint resting at `mix` 0 that no animation keys **away from 0** (§4.9, §4.12). `IkConstraint.update` returns on `mix === 0`, so it sits in the update cache and moves nothing. The keys are read the way `A23`/`A36`/`A37` read theirs — every value the loaded timeline poses on its `mix` channel, Bezier samples included — so a timeline keying 0 only is no rescue: [measured] it poses every bone exactly where the same rig with no constraint does. Live is the runtime's `!== 0`, so a negative mix is not refused. `ik constraint "C" has mix 0 at setup and none of the 1 animation keys its mix above 0; update() returns on mix 0, so "upper" never reaches for "goal" — rest it above 0, or key its mix above 0 in an animation, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "ik", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused: `ik constraint "C" is declared in the rig spec as invariants.consumerDrivenMix, and the file already switches it on — … — so the declaration exempts nothing; drop the entry`. 🔩 **The shape clause** (issue #1205) reads `bones` whatever the mix, declared or not: an ik over more than two bones — `ik constraint "C" names 3 bones ("a", "b", "c"); the solver applies one or two, so a constraint over 3 moves nothing — name one bone, or two where the second is the first's child` — and a pair whose second bone's parent is not the first — `ik constraint "C": "b" is not a child of "a" ("m" stands between), so the two-bone solve is not of the chain drawn — name "b"'s own parent as the first bone, or make "b" a child of "a"`, or `("a" is not above it; its parent is "p")` when `a` is not an ancestor at all. [measured, rigc's fixtures through spine-core] the three-bone and four-bone ik posed every bone exactly where no constraint does; a pair skipping a bone missed by 18.95–22.25, a pair with a bone between at the child's origin by 0.27–2.60, and one at the parent's origin landed only while that bone held its identity (turned 15°: 3.50–4.11 off). The fix is the rig spec's `bones` (§3.5), and a rig spec states neither shape — the parser refuses both with the same sentence (§5.1) — so this clause is for a file a rig spec did not write. On the core entry the model document's reader refuses both first, as `A00_MODEL_READ`. **SKIP** when the skeleton declares no ik constraint, and **SKIP by name** when every ik constraint it declares is declared consumer-driven: `every ik constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the rule measures that one and the stats line names the declared (`ikConsumerDriven`) |
|
|
6413
6422
|
| `A48_TRANSFORM_CONSTRAINT_NOT_MUTED_THROUGHOUT` | both | a transform constraint none of whose mixes **for a property it drives** is away from 0 at setup or on any value an animation poses (§4.10, §4.12), or one whose `properties` name no `to` at all. A property is applied only when its own mix `!== 0`, and a key that omits a mix reads it as 1, so the six-mix early return of `TransformConstraint.update` would take a key of `mixRotate: 0` alone as a rescue — [measured] that key, and one keying `mixX` 1 on a rotate-only constraint, pose every bone exactly where no constraint does. A negative mix runs, and five transforms in the editor's example exports rest at −1. `transform constraint "C" drives rotate and has mixRotate 0 at setup, and none of the 1 animation keys its mix above 0; a mix is read only for a property the constraint drives, and update() skips each one at 0, so nothing ever moves "follower" — rest mixRotate above 0, or key its mix above 0 in an animation, or declare that the consumer drives its mix, in the rig spec as invariants.consumerDrivenMix: [{ "constraint": "C", "type": "transform", "why": … }]`. A constraint the rig spec declares in `invariants.consumerDrivenMix` (§3.7) is not measured, and one the file also switches on is refused as `A47`'s is; one that drives no property is refused with its own sentence declared or not, since no mix it carries is read by anybody. **SKIP** when the skeleton declares no transform constraint, and **SKIP by name** when every transform constraint it declares is declared consumer-driven: `every transform constraint here is declared in the rig spec as invariants.consumerDrivenMix, so its mix is the consumer's to set and nothing in this file shows it moving — "C" (why: …): it rests muted and none of the 1 animation keys its mix above 0`. With a live one beside it the stats line names the declared (`transformConsumerDriven`) |
|
|
6414
6423
|
| `A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP` | renderer | two regions on one page whose rectangles overlap **and** whose footprints overlap — so one drawing samples texels that are the other's. A region's **footprint** is what samples it: a region a mesh names (its `path`, else its name, every frame of a `sequence` included) draws the mesh's hull loop and its triangles, its UVs placed on the page as `MeshAttachment.computeUVs` places them (a **linked mesh** draws its source's); a region a region attachment names, or that nothing names, draws its whole rectangle — and so does a mesh region whose UVs leave 0..1, whose hull or triangles do not index its vertices, or that is turned by `rotate:`, and every region when a `sequence` cannot be read. For two rectangles that is exactly the clause `A06` held until issue #1099 — an editor alias, a sequence frame, two rectangles moved over the same texels are refused as they were — and it accepts what `--pack-shape polygon` writes: a neighbour inside a mesh's rectangle where the hull is not (§0.1). **Touching is not overlapping**: interiors are compared, every comparison `FOOTPRINT_SLACK` (1e-6 texels) in favour of apart. `regions "ear_l" (341,696 112x420) and "mouth" (332,709 96x48) overlap on page "skeleton.png", and so do their footprints — "ear_l" draws the hull of mesh "ear_l" (skin "default", slot "ear_l"), "mouth" draws its whole rectangle, which region attachment "mouth" (skin "default", slot "mouth") draws; what they draw overlaps by 1916.5512 texel(s) of area inside 347,709 81x48. One drawing samples texels that are the other's: move one region clear of the other's footprint, or pack the page again` — `gallery/nod`'s polygon pack with `mouth` moved onto a triangle of the `ear_l` mesh (`PK88`). Nothing about the **padding** is read — the atlas and the model document do not state it — and the `2 · padding` the packer keeps is the packer's own (`PK81`). A renderer rule because two regions over the same texels is what an editor's packer writes for an alias or a sequence: 49 pairs on four of the ten atlases in `examples/`. **SKIP** when the atlas declares no page or no region, and when no page carries two regions — every unpacked build |
|
|
6415
6424
|
| `A50_STAGE_BOX_IS_THE_STAGE` | both | the stage box a rig asked for (`skeleton.stageBox`, §3.1) is not the stage the model document states. Read on the data a consumer reads and on the pose it reads: the slot carries, in the `default` skin under the asked name, an unweighted bounding box of four vertices whose **stored** vertices are the stage's corners exactly — `(x, y)`, `(x + w, y)`, `(x + w, y + h)`, `(x, y + h)`, as the float32 the runtime loads — and whose **world** vertices at the setup pose (constraints applied, no physics, no skin set) are those corners as the runtime poses them on an unrotated root, compared on the header's 1e-6 grid at float32. `the stage box (slot "stage", attachment "stage"): vertex 1 is stored as (65, 0) and the stage's corner is (64, 0) — the stage 0,0 64x48, bottom-left first and counter-clockwise`; a root moved at setup reads *vertex 0 poses at (1, 0) at the setup pose and the stage's corner poses at (0, 0) on an unrotated root, on the header's grid — something moves the box's bone at setup (a constraint, or a pose the root does not state)*; a slot the skeleton lacks, no attachment of that name, another type, or a weighted box are each named too. Through spine-core it reads `findSlot`, `defaultSkin.getAttachment` and `computeWorldVertices` on a fresh skeleton after `setupPose()` and `updateWorldTransform(Physics.none)`; on the model side the document's record and rigc's core poser, held to spine-core's poses at tolerance 0 (`PG06`, `PG07`). **SKIP** where nothing asks: *nothing asks for a stage box: no rigc-compiled/3 model document beside this skeleton states one …* — a rig without the field, and any skeleton with no `/3` document beside it, an export among them |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.15.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": {
|
|
@@ -14,16 +14,83 @@
|
|
|
14
14
|
*
|
|
15
15
|
* Moved out of `src/validate.ts` whole: the bones, the target, the setup mix
|
|
16
16
|
* and the keyed ones are the rig's, the declaration the rig spec's.
|
|
17
|
+
*
|
|
18
|
+
* 🔩 A second clause reads the constraint's `bones` as a shape, whatever its
|
|
19
|
+
* mix (issue #1205): more than two bones, or a pair whose second bone's
|
|
20
|
+
* parent is not the first, is a constraint the solver cannot apply as drawn
|
|
21
|
+
* — `ikShapeFault` below, whose sentence the rig-spec parser prints too. A
|
|
22
|
+
* clause rather than an assertion of its own because it asks this body's
|
|
23
|
+
* question of the same object — does this ik do what it says — and a new
|
|
24
|
+
* registry entry would move every count the documents state.
|
|
17
25
|
*/
|
|
18
26
|
import type { Verdicts } from '../harness.ts';
|
|
19
27
|
import type { ConstraintFacts } from '../facts/constraints.ts';
|
|
20
28
|
import { consumerDriven, consumerSkip, declaredButLive, declareIt, ikLive, noneKeysItsMixAbove0, REST_OR_KEY_ITS_MIX, switchedOn } from '../constraint_words.ts';
|
|
21
29
|
import type { RigInfo } from '../../types.ts';
|
|
22
30
|
|
|
31
|
+
/**
|
|
32
|
+
* What is wrong with an ik constraint's `bones` as a shape, in the sentence
|
|
33
|
+
* both the rig-spec parser and A47 print — or `null` when the shape is one
|
|
34
|
+
* the solver applies as drawn (issue #1205).
|
|
35
|
+
*
|
|
36
|
+
* - **A count other than one or two moves nothing.** The runtime's update
|
|
37
|
+
* switches on the count with a case for one bone and one for two, so a
|
|
38
|
+
* constraint over three or more is in the update cache and applies nothing
|
|
39
|
+
* [measured: a chain under an ik over three bones and over four posed every
|
|
40
|
+
* bone exactly where the same rig with no constraint did, 49 samples,
|
|
41
|
+
* tolerance 0]. A list of none is refused before this (the compiler's
|
|
42
|
+
* "needs a non-empty bones array"; an export's own loader).
|
|
43
|
+
* - **A pair whose second bone's parent is not the first solves another
|
|
44
|
+
* triangle.** The two-bone solve places the second bone through the first's
|
|
45
|
+
* matrix from the second's own local offset, so whatever stands between
|
|
46
|
+
* them is left out of the chain it solves [measured on the same chain: a
|
|
47
|
+
* bone between them at the child's origin left the tip 0.27–2.60 off the
|
|
48
|
+
* target, a pair skipping a bone 18.95–22.25 off, and a zero-offset bone
|
|
49
|
+
* between them at the parent's origin landed only while it held its
|
|
50
|
+
* identity — turned 15° in setup, 3.50–4.11 off in every frame]. The rule
|
|
51
|
+
* reads the parent and not the pose, because a pose that happens to agree
|
|
52
|
+
* is not a rig that does. rigc's own reader of the model document refuses
|
|
53
|
+
* both shapes too (`A00_MODEL_READ` on the core entry); this is the same
|
|
54
|
+
* refusal on the side that reads a Spine file.
|
|
55
|
+
*
|
|
56
|
+
* `secondAncestors` is the second bone's ancestors, parent first, as the
|
|
57
|
+
* facts carry them.
|
|
58
|
+
*/
|
|
59
|
+
export function ikShapeFault(name: string, bones: readonly string[], secondAncestors: readonly string[]): string | null {
|
|
60
|
+
const quoted = (list: readonly string[]): string => list.map((b) => `"${b}"`).join(', ');
|
|
61
|
+
if (bones.length > 2) {
|
|
62
|
+
return (
|
|
63
|
+
`ik constraint "${name}" names ${bones.length} bones (${quoted(bones)}); the solver applies one or two, so a constraint ` +
|
|
64
|
+
`over ${bones.length} moves nothing — name one bone, or two where the second is the first's child`
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
if (bones.length !== 2 || secondAncestors[0] === bones[0]) return null;
|
|
68
|
+
const [first, second] = bones;
|
|
69
|
+
const at = secondAncestors.indexOf(first);
|
|
70
|
+
const why =
|
|
71
|
+
at > 0
|
|
72
|
+
? `${quoted(secondAncestors.slice(0, at))} ${at === 1 ? 'stands' : 'stand'} between`
|
|
73
|
+
: secondAncestors.length === 0
|
|
74
|
+
? `"${second}" has no parent`
|
|
75
|
+
: `"${first}" is not above it; its parent is "${secondAncestors[0]}"`;
|
|
76
|
+
return (
|
|
77
|
+
`ik constraint "${name}": "${second}" is not a child of "${first}" (${why}), so the two-bone solve is not of the ` +
|
|
78
|
+
`chain drawn — name "${second}"'s own parent as the first bone, or make "${second}" a child of "${first}"`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
23
82
|
export function a47IkConstraintNotMutedThroughout({ fail, skip, stats }: Verdicts, facts: ConstraintFacts, rig: RigInfo | undefined): void {
|
|
24
83
|
const NAME = 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT';
|
|
25
84
|
const constraints = facts.constraints.flatMap((c, index) => (c.ik === undefined ? [] : [{ name: c.name, ik: c.ik, index }]));
|
|
26
85
|
if (!constraints.length) return skip(NAME, 'the skeleton declares no ik constraint');
|
|
86
|
+
// The shape first, whatever the mix: a constraint the solver cannot apply as drawn is not exempted by a declaration of who sets its mix.
|
|
87
|
+
let shapeFaults = 0;
|
|
88
|
+
for (const { name, ik } of constraints) {
|
|
89
|
+
const fault = ikShapeFault(name, ik.bones, ik.secondAncestors);
|
|
90
|
+
if (fault === null) continue;
|
|
91
|
+
shapeFaults++;
|
|
92
|
+
fail(NAME, fault);
|
|
93
|
+
}
|
|
27
94
|
// Channel 0 is `mix`; the other four are softness, bend direction, compress and stretch.
|
|
28
95
|
const ikSwitchedOn = switchedOn(facts, (timeline) => timeline.kind === 'ik', 1, (_timeline, value, channel) => channel === 0 && ikLive(value));
|
|
29
96
|
const declared = consumerDriven(rig, 'ik');
|
|
@@ -55,5 +122,5 @@ export function a47IkConstraintNotMutedThroughout({ fail, skip, stats }: Verdict
|
|
|
55
122
|
);
|
|
56
123
|
}
|
|
57
124
|
if (exempt.length) stats.ikConsumerDriven = exempt.map(([name]) => name).join(',');
|
|
58
|
-
if (exempt.length === constraints.length) return skip(NAME, consumerSkip('ik', exempt, facts.animations));
|
|
125
|
+
if (exempt.length === constraints.length && shapeFaults === 0) return skip(NAME, consumerSkip('ik', exempt, facts.animations));
|
|
59
126
|
}
|
|
@@ -79,7 +79,19 @@ export interface ConstraintEntry {
|
|
|
79
79
|
readonly scale: number;
|
|
80
80
|
readonly mix: number;
|
|
81
81
|
};
|
|
82
|
-
readonly ik?: {
|
|
82
|
+
readonly ik?: {
|
|
83
|
+
readonly bones: readonly string[];
|
|
84
|
+
readonly target: string;
|
|
85
|
+
readonly mix: number;
|
|
86
|
+
/**
|
|
87
|
+
* For an ik over exactly two bones, the second bone's ancestors, its
|
|
88
|
+
* parent first and the root last; empty for any other count (issue
|
|
89
|
+
* #1205). The two-bone solve places the second bone through the first's
|
|
90
|
+
* matrix from the second's own local offset, so it is the solve of the
|
|
91
|
+
* chain drawn only when the first entry here is the first bone.
|
|
92
|
+
*/
|
|
93
|
+
readonly secondAncestors: readonly string[];
|
|
94
|
+
};
|
|
83
95
|
/**
|
|
84
96
|
* A transform constraint's six mix channels in frame order — `mixRotate`,
|
|
85
97
|
* `mixX`, `mixY`, `mixScaleX`, `mixScaleY`, `mixShearY` — each the field's
|
|
@@ -125,8 +125,14 @@ function entryOf(doc: CompiledDocument, kind: ConstraintKind, name: string, reco
|
|
|
125
125
|
},
|
|
126
126
|
};
|
|
127
127
|
}
|
|
128
|
-
case 'ik':
|
|
129
|
-
|
|
128
|
+
case 'ik': {
|
|
129
|
+
// The second bone's ancestors, parent first (issue #1205). The core's reader refuses a pair that is not a parent and its child, so on this side the first entry is always the first bone.
|
|
130
|
+
const secondAncestors: string[] = [];
|
|
131
|
+
if (record.bones.length === 2) {
|
|
132
|
+
for (let at = doc.bones.find((b) => b.name === record.bones[1])?.parent; at !== undefined; at = doc.bones.find((b) => b.name === at)?.parent) secondAncestors.push(at);
|
|
133
|
+
}
|
|
134
|
+
return { ...base, ik: { bones: record.bones, target: record.target, mix: record.mix, secondAncestors } };
|
|
135
|
+
}
|
|
130
136
|
case 'transform':
|
|
131
137
|
return {
|
|
132
138
|
...base,
|
package/src/mesh.ts
CHANGED
|
@@ -1075,7 +1075,24 @@ export function traceAlphaOutline(
|
|
|
1075
1075
|
const inside = new Uint8Array(w * h);
|
|
1076
1076
|
for (let i = 0; i < inside.length; i++) inside[i] = label[i] === biggest ? 1 : 0;
|
|
1077
1077
|
|
|
1078
|
-
|
|
1078
|
+
// The silhouette the walk traces is the island WITH ITS HOLES FILLED, and it is
|
|
1079
|
+
// built before the pinch scan because the scan has to read the same pixels the
|
|
1080
|
+
// walk does. A corner where two holes meet diagonally is inside the filled
|
|
1081
|
+
// silhouette, and the outer outline never visits it; scanning the island
|
|
1082
|
+
// before the fill refused such a part for a pinch no outline passes through
|
|
1083
|
+
// (issue #1209).
|
|
1084
|
+
//
|
|
1085
|
+
// ⚠️ A hole is background that no 8-connected path of background reaches from
|
|
1086
|
+
// the border, not merely no 4-connected one. The island is 4-connected, so two
|
|
1087
|
+
// background pixels that touch only at a corner are connected through it — the
|
|
1088
|
+
// same duality the walk's clockwise rule keeps. With a 4-connected flood a
|
|
1089
|
+
// transparent pixel that touches the outside only diagonally would read as a
|
|
1090
|
+
// hole, the fill would close the pinch at that corner, and the part would trace
|
|
1091
|
+
// a region the art does not enclose: that is filling a pinch, not a hole. On
|
|
1092
|
+
// every part this function accepts the two floods fill the same pixels — they
|
|
1093
|
+
// can only differ across a corner the scan below refuses.
|
|
1094
|
+
const { filled, holePixels } = fillEnclosed(inside, w, h, 8);
|
|
1095
|
+
const at = (x: number, y: number): number => (x < 0 || y < 0 || x >= w || y >= h ? 0 : filled[y * w + x]);
|
|
1079
1096
|
/** Outgoing cracks at one lattice corner, as direction indices. */
|
|
1080
1097
|
const outgoing = (cx: number, cy: number): number[] => {
|
|
1081
1098
|
const dirs: number[] = [];
|
|
@@ -1087,12 +1104,17 @@ export function traceAlphaOutline(
|
|
|
1087
1104
|
};
|
|
1088
1105
|
|
|
1089
1106
|
// A DIAGONAL PINCH is refused before the walk, not during it. At a corner where
|
|
1090
|
-
// two
|
|
1091
|
-
// cracks leave the same point — so the boundary is not a
|
|
1092
|
-
// any more, and whichever pairing the walk chooses it
|
|
1093
|
-
// point twice or leaves part of the outline
|
|
1094
|
-
// second one produces a perfectly valid mesh of
|
|
1095
|
-
// it costs one pass and names the pixel corner.
|
|
1107
|
+
// two pixels of the filled silhouette meet diagonally with background on the
|
|
1108
|
+
// other diagonal, two cracks leave the same point — so the boundary is not a
|
|
1109
|
+
// set of simple loops any more, and whichever pairing the walk chooses it
|
|
1110
|
+
// either passes through one point twice or leaves part of the outline
|
|
1111
|
+
// untraced. Both are silent: the second one produces a perfectly valid mesh of
|
|
1112
|
+
// the wrong region. Scanning for it costs one pass and names the pixel corner.
|
|
1113
|
+
// Background here is what the fill left, so both pixels on that diagonal are
|
|
1114
|
+
// reached from outside the part: the corner is on the outer outline itself.
|
|
1115
|
+
// The remedy names a direction for each outcome because a threshold moves art
|
|
1116
|
+
// one way only: raising it removes pixels, which can open a pinch and never
|
|
1117
|
+
// close one, and lowering it adds them.
|
|
1096
1118
|
for (let cy = 0; cy <= h; cy++) {
|
|
1097
1119
|
for (let cx = 0; cx <= w; cx++) {
|
|
1098
1120
|
const tl = at(cx - 1, cy - 1);
|
|
@@ -1102,16 +1124,19 @@ export function traceAlphaOutline(
|
|
|
1102
1124
|
if ((tl && br && !tr && !bl) || (tr && bl && !tl && !br)) {
|
|
1103
1125
|
throw new MeshError(
|
|
1104
1126
|
`the alpha silhouette pinches to a single point at pixel corner (${cx},${cy}), where two parts of the art ` +
|
|
1105
|
-
'meet diagonally — one outline cannot pass through one point twice.
|
|
1106
|
-
'pinch
|
|
1127
|
+
'meet diagonally — one outline cannot pass through one point twice. Move the alpha threshold so the ' +
|
|
1128
|
+
'pinch opens (raise it) or closes (lower it), or author the geometry as weights',
|
|
1107
1129
|
);
|
|
1108
1130
|
}
|
|
1109
1131
|
}
|
|
1110
1132
|
}
|
|
1111
1133
|
|
|
1134
|
+
// The first filled pixel in row order is an island pixel: a hole's upper
|
|
1135
|
+
// neighbour is never background the border reaches, so no hole sits on the
|
|
1136
|
+
// silhouette's top row.
|
|
1112
1137
|
let startAt = -1;
|
|
1113
|
-
for (let i = 0; i <
|
|
1114
|
-
if (
|
|
1138
|
+
for (let i = 0; i < filled.length; i++) {
|
|
1139
|
+
if (filled[i]) {
|
|
1115
1140
|
startAt = i;
|
|
1116
1141
|
break;
|
|
1117
1142
|
}
|
|
@@ -1119,10 +1144,9 @@ export function traceAlphaOutline(
|
|
|
1119
1144
|
const sx = startAt % w;
|
|
1120
1145
|
const sy = (startAt - sx) / w;
|
|
1121
1146
|
// With no pinch anywhere, every corner has at most one outgoing crack, so the
|
|
1122
|
-
// boundary is a
|
|
1123
|
-
//
|
|
1124
|
-
//
|
|
1125
|
-
// makes "holes are filled" a property of the trace rather than a later repair.
|
|
1147
|
+
// boundary is a simple loop and this walk traces it. The holes have no loop
|
|
1148
|
+
// of their own to enter, because the walk reads the filled silhouette: their
|
|
1149
|
+
// pixels are inside the outline and draw nothing, and `holePixels` counts them.
|
|
1126
1150
|
const outline: Array<[number, number]> = [];
|
|
1127
1151
|
let cx = sx;
|
|
1128
1152
|
let cy = sy;
|
|
@@ -1144,7 +1168,6 @@ export function traceAlphaOutline(
|
|
|
1144
1168
|
dir = next;
|
|
1145
1169
|
}
|
|
1146
1170
|
|
|
1147
|
-
const { filled, holePixels } = fillEnclosed(inside, w, h);
|
|
1148
1171
|
return { outline, artPixels, islandPixels: sizes[biggest - 1], islands: sizes.length, holePixels, filled };
|
|
1149
1172
|
}
|
|
1150
1173
|
|
|
@@ -1155,8 +1178,19 @@ export function traceAlphaOutline(
|
|
|
1155
1178
|
* than by a winding rule — which is what makes it answer the same question for a
|
|
1156
1179
|
* mask of one island and a mask of several, and is why the authored-mesh
|
|
1157
1180
|
* measurement can pass it all the art where the trace passes it one island.
|
|
1181
|
+
*
|
|
1182
|
+
* `background` is how the flood steps: 4 through edges, 8 through corners too.
|
|
1183
|
+
* The trace floods 8 — the dual of its 4-connected island, so background that
|
|
1184
|
+
* meets the outside at a corner is outside — and says why where it calls this.
|
|
1185
|
+
* The authored-mesh measurement floods 4, as it always has; the two agree on
|
|
1186
|
+
* every mask the trace accepts.
|
|
1158
1187
|
*/
|
|
1159
|
-
function fillEnclosed(
|
|
1188
|
+
function fillEnclosed(
|
|
1189
|
+
inside: Uint8Array,
|
|
1190
|
+
w: number,
|
|
1191
|
+
h: number,
|
|
1192
|
+
background: 4 | 8,
|
|
1193
|
+
): { filled: Uint8Array; holePixels: number } {
|
|
1160
1194
|
const outsideReach = new Uint8Array(w * h);
|
|
1161
1195
|
const queue: number[] = [];
|
|
1162
1196
|
const seed = (x: number, y: number): void => {
|
|
@@ -1182,6 +1216,12 @@ function fillEnclosed(inside: Uint8Array, w: number, h: number): { filled: Uint8
|
|
|
1182
1216
|
seed(x + 1, y);
|
|
1183
1217
|
seed(x, y - 1);
|
|
1184
1218
|
seed(x, y + 1);
|
|
1219
|
+
if (background === 8) {
|
|
1220
|
+
seed(x - 1, y - 1);
|
|
1221
|
+
seed(x + 1, y - 1);
|
|
1222
|
+
seed(x - 1, y + 1);
|
|
1223
|
+
seed(x + 1, y + 1);
|
|
1224
|
+
}
|
|
1185
1225
|
}
|
|
1186
1226
|
const filled = new Uint8Array(w * h);
|
|
1187
1227
|
let holePixels = 0;
|
|
@@ -1340,7 +1380,7 @@ export function measureAuthoredMeshFit(
|
|
|
1340
1380
|
): MeshFitReport {
|
|
1341
1381
|
const { width: w, height: h } = mask;
|
|
1342
1382
|
const art = artOf(mask, threshold);
|
|
1343
|
-
const { filled } = fillEnclosed(art, w, h);
|
|
1383
|
+
const { filled } = fillEnclosed(art, w, h, 4);
|
|
1344
1384
|
const covered = rasteriseTriangles(points, triangles, w, h);
|
|
1345
1385
|
let artPixels = 0;
|
|
1346
1386
|
let coveredArt = 0;
|
package/src/rig.ts
CHANGED
|
@@ -59,6 +59,7 @@
|
|
|
59
59
|
* `slots` below for the join rule) and leaves `skins` empty. A foreign skeleton
|
|
60
60
|
* with no manifest at all declares them here.
|
|
61
61
|
*/
|
|
62
|
+
import { ikShapeFault } from './assertions/bodies/a47.ts';
|
|
62
63
|
import { CompileError, NotImplementedError } from './errors.ts';
|
|
63
64
|
import { dottedPath, refuseNumbersTheFileCannotCarry, refuseUnknownKeys, refuseValuesOfTheWrongType, refuseValuesOutsideTheirSet } from './keys.ts';
|
|
64
65
|
import type { ShapeVisit, SpecEnumTable, SpecValueType } from './keys.ts';
|
|
@@ -1352,7 +1353,13 @@ export type RigScaleYMode = 'none' | 'uniform' | 'volume' | 'None' | 'Uniform' |
|
|
|
1352
1353
|
/** `type: "ik"` (`:149-176`). `scaleY` is 4.3's replacement for 4.2's `uniform`. */
|
|
1353
1354
|
export interface RigIkConstraint extends RigConstraintCommon {
|
|
1354
1355
|
type: 'ik';
|
|
1355
|
-
/**
|
|
1356
|
+
/**
|
|
1357
|
+
* One bone, or two where the second's parent is the first; resolved by
|
|
1358
|
+
* name, and a miss throws in the parser. Any other shape is refused by name
|
|
1359
|
+
* (issue #1205): the runtime applies nothing for three or more, and solves
|
|
1360
|
+
* a pair through the first bone's matrix from the second's local offset, so
|
|
1361
|
+
* a bone standing between them is left out of the triangle it solves.
|
|
1362
|
+
*/
|
|
1356
1363
|
bones: string[];
|
|
1357
1364
|
target: string;
|
|
1358
1365
|
/** `ConstraintData.ts:50`. Absent → `None`. */
|
|
@@ -2682,6 +2689,20 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
|
|
|
2682
2689
|
constraintsDeclared.push(declared);
|
|
2683
2690
|
constraintFacts.set(constraintAt(declared.type, declared.name), declared);
|
|
2684
2691
|
constraintKinds.set(declared.name, [...(constraintKinds.get(declared.name) ?? []), declared.type]);
|
|
2692
|
+
// An ik's `bones` as a shape (issue #1205): more than two, or a pair that is
|
|
2693
|
+
// not a parent and its child, both load and build green and the solver then
|
|
2694
|
+
// moves nothing or solves a triangle nobody drew — `ikShapeFault` says which.
|
|
2695
|
+
// Read only once every name resolves to a declared bone, so a misspelling is
|
|
2696
|
+
// still the compiler's refusal by name rather than a shape it does not have.
|
|
2697
|
+
const ikBones = constraint.type === 'ik' ? constraint.bones : undefined;
|
|
2698
|
+
if (Array.isArray(ikBones) && ikBones.every((b): b is string => typeof b === 'string' && seen.has(b))) {
|
|
2699
|
+
const secondAncestors: string[] = [];
|
|
2700
|
+
if (ikBones.length === 2) {
|
|
2701
|
+
for (let at = spec.bones.find((b) => b.name === ikBones[1])?.parent; at !== undefined; at = spec.bones.find((b) => b.name === at)?.parent) secondAncestors.push(at);
|
|
2702
|
+
}
|
|
2703
|
+
const fault = ikShapeFault(constraint.name, ikBones, secondAncestors);
|
|
2704
|
+
if (fault !== null) throw new CompileError(`${where}: ${fault}`);
|
|
2705
|
+
}
|
|
2685
2706
|
}
|
|
2686
2707
|
|
|
2687
2708
|
// `invariants.consumerDrivenMix` — the second field in `invariants` that TURNS
|
package/src/validate.ts
CHANGED
|
@@ -1641,7 +1641,9 @@ export function spineConstraintFacts(data: ReturnType<SkeletonJson['readSkeleton
|
|
|
1641
1641
|
};
|
|
1642
1642
|
}
|
|
1643
1643
|
if (c instanceof IkConstraintData) {
|
|
1644
|
-
|
|
1644
|
+
const secondAncestors: string[] = [];
|
|
1645
|
+
if (c.bones.length === 2) for (let at = c.bones[1].parent; at !== null; at = at.parent) secondAncestors.push(at.name);
|
|
1646
|
+
return { kind: 'ik', name: c.name, runtimeClass, ik: { bones: c.bones.map((b) => b.name), target: c.target.name, mix: c.setupPose.mix, secondAncestors } };
|
|
1645
1647
|
}
|
|
1646
1648
|
if (c instanceof TransformConstraintData) {
|
|
1647
1649
|
const pose = c.setupPose;
|
|
@@ -20,6 +20,22 @@
|
|
|
20
20
|
* It ran from a shell script in a local scratch directory. A tool nobody can
|
|
21
21
|
* find is not a tool, hence this file.
|
|
22
22
|
*
|
|
23
|
+
* ## Which editor the report names
|
|
24
|
+
*
|
|
25
|
+
* Step 0's version line is the one place the report says which editor the trip
|
|
26
|
+
* ran on. It is read from the editor's `--version` started with the SAME `-u`
|
|
27
|
+
* the import and the export carry (`--editor-version`), so a pinned run names
|
|
28
|
+
* the pinned editor, and says so on that line: `(read from --version under -u
|
|
29
|
+
* <v>, …)`. An unpinned run prints the line alone, read from `--version` with no
|
|
30
|
+
* `-u` — the editor the launcher starts by default. Once the export exists,
|
|
31
|
+
* step 2 holds that version against the export's own `skeleton.spine`, prints
|
|
32
|
+
* one line saying which it read and that the two agree, and when they DISAGREE
|
|
33
|
+
* prints a `FAIL` naming both values and ends the run non-zero: a version line
|
|
34
|
+
* that names an editor the trip did not export on is a fault in the instrument,
|
|
35
|
+
* and a green run carrying it would be a measurement naming the wrong
|
|
36
|
+
* instrument (issue #1199, where a bare `--version` named the launcher's
|
|
37
|
+
* default, 4.3.26, on a trip pinned to and exported by 4.3.23).
|
|
38
|
+
*
|
|
23
39
|
* ## What it is not
|
|
24
40
|
*
|
|
25
41
|
* 🔒 **It is not a way to get Spine data without the editor** — it is the
|
|
@@ -182,10 +198,82 @@ function bundleName(plist: string): string | null {
|
|
|
182
198
|
*/
|
|
183
199
|
const EDITOR_VERSION_LINE = /^Spine[ \t]+\d+\.\d+\.\d+\b[^\n]*$/m;
|
|
184
200
|
|
|
185
|
-
/**
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
201
|
+
/**
|
|
202
|
+
* What step 0 prints for the editor's version — its own line, or a sentence
|
|
203
|
+
* saying it was not there — and the `x.y.z` that line names, which is what the
|
|
204
|
+
* export's own `skeleton.spine` is checked against once the export exists
|
|
205
|
+
* (issue #1199). `version` is null exactly when `line` is the absence.
|
|
206
|
+
*/
|
|
207
|
+
function editorVersion(output: string): { line: string; version: string | null } {
|
|
208
|
+
const found = EDITOR_VERSION_LINE.exec(output);
|
|
209
|
+
if (found === null) return { line: 'editor version: not found in --version output', version: null };
|
|
210
|
+
return { line: found[0].trim(), version: /^Spine[ \t]+(\d+\.\d+\.\d+)/.exec(found[0])?.[1] ?? null };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Where step 0 read the editor's version, in the words the report prints it in
|
|
215
|
+
* (issue #1199): the `--version` call, and the `-u` it carried or the absence of
|
|
216
|
+
* one — which on an unpinned run means the editor the launcher starts by default.
|
|
217
|
+
*/
|
|
218
|
+
function versionSource(pinned: string | null): string {
|
|
219
|
+
return pinned === null
|
|
220
|
+
? "read from --version with no -u: the editor the launcher starts by default"
|
|
221
|
+
: `read from --version under -u ${pinned}, the pin every editor call of this trip carries`;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The `skeleton.spine` an export declares, or null when it declares none or the
|
|
226
|
+
* file cannot be read as a skeleton — read off the FILE, as `shapeOf` reads it,
|
|
227
|
+
* because it is the editor's own statement of the data version it exported.
|
|
228
|
+
* Null is not swallowed: the caller states that nothing was there to check.
|
|
229
|
+
*/
|
|
230
|
+
function declaredSpineVersion(path: string): string | null {
|
|
231
|
+
try {
|
|
232
|
+
const parsed: unknown = JSON.parse(readFileSync(path, 'utf8'));
|
|
233
|
+
if (!isRecord(parsed) || !isRecord(parsed.skeleton)) return null;
|
|
234
|
+
const spine = parsed.skeleton.spine;
|
|
235
|
+
return typeof spine === 'string' ? spine : null;
|
|
236
|
+
} catch {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* The line step 2 prints once the export exists: the version step 0 read held
|
|
243
|
+
* against the export's own `skeleton.spine` (issue #1199). `fail` is true only
|
|
244
|
+
* when both are there and differ — a disagreement is a finding about the
|
|
245
|
+
* INSTRUMENT, not a pass, so it fails the trip; a side with nothing to compare is
|
|
246
|
+
* stated and fails nothing, the way an absent version line has never been a
|
|
247
|
+
* refusal (#1077).
|
|
248
|
+
*/
|
|
249
|
+
export function versionAgreement(
|
|
250
|
+
read: string | null,
|
|
251
|
+
pinned: string | null,
|
|
252
|
+
declared: string | null,
|
|
253
|
+
): { line: string; fail: boolean } {
|
|
254
|
+
const source = versionSource(pinned);
|
|
255
|
+
if (declared === null) {
|
|
256
|
+
return {
|
|
257
|
+
line: ` the export declares no skeleton.spine, so the version step 0 ${read === null ? 'did not find' : `read (${read}, ${source})`} is not checked against it`,
|
|
258
|
+
fail: false,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
if (read === null) {
|
|
262
|
+
return {
|
|
263
|
+
line: ` the export declares skeleton.spine "${declared}"; step 0 found no version line (${source}) to check it against`,
|
|
264
|
+
fail: false,
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
if (read === declared) {
|
|
268
|
+
return { line: ` the export declares skeleton.spine "${declared}", the version step 0 read (${source})`, fail: false };
|
|
269
|
+
}
|
|
270
|
+
return {
|
|
271
|
+
line:
|
|
272
|
+
` FAIL step 0 read the editor as ${read} (${source}) and the export declares skeleton.spine "${declared}" — ` +
|
|
273
|
+
'the version line names an editor this trip did not export on, which is a fault in the instrument rather ' +
|
|
274
|
+
'than in the rig, so the trip is not green',
|
|
275
|
+
fail: true,
|
|
276
|
+
};
|
|
189
277
|
}
|
|
190
278
|
|
|
191
279
|
/** The trial naming itself in its own `--version` output, or null. */
|
|
@@ -1286,6 +1374,10 @@ function main(): void {
|
|
|
1286
1374
|
emit(` ${run(rigc.cmd, [...rigc.prefix, '--version'], 60).stdout.trim()}`);
|
|
1287
1375
|
|
|
1288
1376
|
let exportedJson: string;
|
|
1377
|
+
/** The `x.y.z` step 0 read off the editor, held against the export at step 2 (issue #1199). */
|
|
1378
|
+
let editorRead: string | null = null;
|
|
1379
|
+
/** True when the export's `skeleton.spine` and step 0's version both exist and differ. */
|
|
1380
|
+
let versionDisagrees = false;
|
|
1289
1381
|
if (opts.exported !== null) {
|
|
1290
1382
|
exportedJson = opts.exported;
|
|
1291
1383
|
emit(` editor NOT RUN — measuring an export the editor already made: ${opts.exported}`);
|
|
@@ -1309,16 +1401,26 @@ function main(): void {
|
|
|
1309
1401
|
const named = trialSignalsFromPath(opts.editor);
|
|
1310
1402
|
if (named.length > 0) fail(trialRefusal(opts.editor, named));
|
|
1311
1403
|
|
|
1312
|
-
const
|
|
1404
|
+
const pin = opts.editorVersion === null ? [] : ['-u', opts.editorVersion];
|
|
1405
|
+
// 🚨 Under the same `-u` the import and the export carry (issue #1199). The
|
|
1406
|
+
// call used to be a bare `--version`, which names the editor the LAUNCHER
|
|
1407
|
+
// starts by default: measured 2026-10-07 on a launcher whose default is
|
|
1408
|
+
// 4.3.26, a trip pinned with `-u 4.3.23` exported `"spine": "4.3.23"` and
|
|
1409
|
+
// step 0 printed `Spine 4.3.26 Professional` — the one line that says which
|
|
1410
|
+
// editor the trip ran on, naming one it did not run on.
|
|
1411
|
+
const ver = run(opts.editor, [...pin, '--version'], 60);
|
|
1313
1412
|
emit(` editor ${opts.editor}`);
|
|
1314
1413
|
// One line, never the output's tail: the tail is the licensee's (issue #1077).
|
|
1315
|
-
|
|
1414
|
+
// A pinned run says where the line came from; an unpinned run prints the line
|
|
1415
|
+
// alone, as it always has, and step 2 states its source beside the export's.
|
|
1416
|
+
const stated = editorVersion(`${ver.stdout}\n${ver.stderr}`);
|
|
1417
|
+
editorRead = stated.version;
|
|
1418
|
+
emit(` ${stated.line}${opts.editorVersion === null ? '' : ` (${versionSource(opts.editorVersion)})`}`);
|
|
1316
1419
|
// The second signal, and the only one that works on a platform whose trial
|
|
1317
1420
|
// path this repository has never seen: the binary's own banner. It costs no
|
|
1318
1421
|
// extra call — `--version` above is one the tool already made.
|
|
1319
1422
|
const introduced = trialSignalFromVersion(`${ver.stdout}\n${ver.stderr}`);
|
|
1320
1423
|
if (introduced !== null) fail(trialRefusal(opts.editor, [introduced]));
|
|
1321
|
-
const pin = opts.editorVersion === null ? [] : ['-u', opts.editorVersion];
|
|
1322
1424
|
|
|
1323
1425
|
emit('');
|
|
1324
1426
|
emit('## 1 import (json -> project)');
|
|
@@ -1353,6 +1455,11 @@ function main(): void {
|
|
|
1353
1455
|
);
|
|
1354
1456
|
}
|
|
1355
1457
|
exportedJson = join(opts.out, 'export', written[0]);
|
|
1458
|
+
// 🔒 The version line checked against what the trip measured, once there is
|
|
1459
|
+
// an export to read (issue #1199): the editor's own `skeleton.spine`.
|
|
1460
|
+
const agreement = versionAgreement(editorRead, opts.editorVersion, declaredSpineVersion(exportedJson));
|
|
1461
|
+
emit(agreement.line);
|
|
1462
|
+
versionDisagrees = agreement.fail;
|
|
1356
1463
|
}
|
|
1357
1464
|
|
|
1358
1465
|
// The candidate: the export beside the build's atlas and pages, which were
|
|
@@ -1518,7 +1625,9 @@ function main(): void {
|
|
|
1518
1625
|
// The verdict is the gate's and EVERY skin's check, not this tool's opinion of
|
|
1519
1626
|
// them: one skin coming back wrong is the whole run coming back wrong, which
|
|
1520
1627
|
// is the half a single un-skinned check could not say.
|
|
1521
|
-
|
|
1628
|
+
// A version line that disagrees with the export is red too (issue #1199): the
|
|
1629
|
+
// report would otherwise name an editor the trip did not run on, green.
|
|
1630
|
+
process.exit(gate.status === 0 && checksClean && rosterClean && !versionDisagrees ? 0 : 1);
|
|
1522
1631
|
}
|
|
1523
1632
|
|
|
1524
1633
|
// ⭐ Guarded so `shapeOf`, `shapeDiff`, `skinsDeclaredBy` and `skinBlocks` can be
|