spine-rigc 2.14.1 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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. It prints the import and export exit codes, the
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
@@ -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.14.1",
3
+ "version": "2.15.0",
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?: { readonly bones: readonly string[]; readonly target: string; readonly mix: number };
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
- return { ...base, ik: { bones: record.bones, target: record.target, mix: record.mix } };
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/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
- /** At least one, resolved by name; a miss throws in the parser. */
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
- return { kind: 'ik', name: c.name, runtimeClass, ik: { bones: c.bones.map((b) => b.name), target: c.target.name, mix: c.setupPose.mix } };
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
- /** What step 0 prints for the editor's version: its own line, or a sentence saying it was not there. */
186
- function editorVersionLine(output: string): string {
187
- const line = EDITOR_VERSION_LINE.exec(output);
188
- return line === null ? 'editor version: not found in --version output' : line[0].trim();
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 ver = run(opts.editor, ['--version'], 60);
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
- emit(` ${editorVersionLine(`${ver.stdout}\n${ver.stderr}`)}`);
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
- process.exit(gate.status === 0 && checksClean && rosterClean ? 0 : 1);
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