spine-rigc 0.22.0 → 0.22.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/docs/AUTHORING.md CHANGED
@@ -1523,7 +1523,7 @@ curve instead of in the keys.
1523
1523
  | --- | --- |
1524
1524
  | `bones` | at least one, in the order they ride the path |
1525
1525
  | `slot` | **required.** The slot whose path attachment they follow (§3.4) |
1526
- | `positionMode` | default `"Percent"`: `position` is a fraction of the arc length. `"Fixed"` makes it world units |
1526
+ | `positionMode` | default `"Percent"`: `position` is a fraction of the measured `lengths` total — **not** of the arc, see §10.6. `"Fixed"` makes it world units, which is the mode a wrong total is visible in |
1527
1527
  | `spacingMode` | default `"Length"` — `Length`, `Fixed`, `Percent` or `Proportional` |
1528
1528
  | `rotateMode` | default `"Tangent"`: each bone turns to the curve's tangent where it sits. `"Chain"`, `"ChainScale"` |
1529
1529
  | `rotation` | default 0. Degrees added after the path's own rotation |
@@ -3344,7 +3344,7 @@ tell a working traversal from a plausible one.
3344
3344
 
3345
3345
  | Group | `property` | Channels | Note |
3346
3346
  | --- | --- | --- | --- |
3347
- | `path` | `position` | 1 | a fraction of the arc length, or world units under `positionMode: "fixed"` |
3347
+ | `path` | `position` | 1 | a fraction of the measured `lengths` total (§10.6 — not the arc), or world units under `positionMode: "fixed"` |
3348
3348
  | `path` | `spacing` | 1 | in the unit `spacingMode` chose |
3349
3349
  | `path` | `mix` | **3** | `[mixRotate, mixX, mixY]` in one key, so a raw `curve` is 12 numbers |
3350
3350
  | `slider` | `time` | 1 | the bone-less slider's own time. A slider WITH a bone takes its time from the bone and this timeline is not what drives it |
@@ -5015,9 +5015,19 @@ frames. Every line is marked with where it comes from:
5015
5015
 
5016
5016
  - 📗 **stated** — quoted or paraphrased from the page linked in the line.
5017
5017
  - 🧩 **inferred** — this guide's reading of those pages. Spine does not say it.
5018
- - 🔬 **observed** — read off the editor's own export of a rigc build in the round
5019
- trip of [issue #285](https://github.com/firejune/rigc/issues/285) (Spine 4.3.23),
5020
- not from a page. Used only where rigc now emits the same thing.
5018
+ - 🔬 **observed** — read off the editor's own export of a rigc build, not from a
5019
+ page. Two round trips stand behind these: [issue
5020
+ #285](https://github.com/firejune/rigc/issues/285) (Spine 4.3.23) and the
5021
+ eight-rig trip of 2026-09-16 (Spine **4.3.26**), whose findings are collected in
5022
+ §10.6. ⚠️ This legend said *"used only where rigc now emits the same thing"*,
5023
+ which was true while every observation had already been adopted; §10.6 then
5024
+ carried one that had **not** been — the path `lengths` disagreement — so an
5025
+ observation is now marked by where it was read, and each says for itself
5026
+ whether rigc agrees with it. ⭐ That outstanding one has since been adopted
5027
+ ([#560](https://github.com/firejune/rigc/issues/560)) and the legend is kept in
5028
+ this shape anyway: the reason to mark an observation by its source rather than
5029
+ by whether rigc follows it is that the second fact goes stale and the first
5030
+ does not.
5021
5031
 
5022
5032
  ### 10.1 Structure
5023
5033
 
@@ -5596,6 +5606,37 @@ never write any of them by hand — and rigc's own output always carries all
5596
5606
  three, because the editor's *import* treats their absence as an export made
5597
5607
  without the box and rebuilds the hull on its own.
5598
5608
 
5609
+ 🔬 🚨 **And the CLI's default export has that box ON, so for anything driven from
5610
+ the command line the ⇒ above is the exception rather than the case.** The
5611
+ sentence is still true of an export made without the box; what is measured is
5612
+ that `-e json` with no export-settings file does not make one. Every field on the
5613
+ nonessential list came back present, unchanged and not zero on round trip 6
5614
+ (2026-09-16, Spine 4.3.26): the header's `fps` (24 on `fields`, the one rig that
5615
+ declares it) and `images`; a mesh's `width`/`height` (64/48) and `edges`
5616
+ (16 entries, identical); the editor colours of a **bounding box** (`3cff6bff`), a
5617
+ **clipping** polygon (`ff3c6bff`) and a **path** (`ff6b3cff`); and a bone's
5618
+ `icon` (`circle`) with its `color`. All eight exports also carry
5619
+ `"audio": "./audio"` — a field rigc never wrote and none of those rigs has any
5620
+ use for — which is the list's own last member arriving unasked. The runtime says
5621
+ the same thing from the other side: `PathAttachment.js`'s doc comment on `color`
5622
+ reads *"Available only when nonessential data was exported"*, and the colour is
5623
+ there.
5624
+
5625
+ 📗 The CLI page documents the form but not the setting: *"If `json` or `binary` is
5626
+ specified instead of a path to an export settings JSON file, then a JSON or
5627
+ binary export is performed using default settings"* —
5628
+ [Command line interface](https://esotericsoftware.com/spine-command-line-interface).
5629
+ ❓ **Neither that page nor the Export page states whether nonessential is on in
5630
+ those defaults**, so the answer above is measured here and documented nowhere.
5631
+ ⇒ In practice: do not plan around fields being dropped. An export you did not
5632
+ personally make without the box is an export that has them, and the round trip is
5633
+ therefore **richer** than the build rather than poorer — which is why #368's
5634
+ hull-and-edges degradation does not return on a second trip (no import warning in
5635
+ any of the eight `roundtrip.log`s, and a five-vertex mesh's `hull: 4` came back
5636
+ `4` rather than recomputed to `5`). A nonessential-**off** trip would need an
5637
+ export-settings JSON, and ❓ the key name inside that file is not documented
5638
+ either.
5639
+
5599
5640
  ⚠️ **A region's `width`/`height` are not on that list.** They are documented with no
5600
5641
  *"assume … if omitted"* default — the same fact R5 states from the parser's side:
5601
5642
  omit them in raw JSON and every UV collapses, in silence. Name an `image`.
@@ -5606,7 +5647,136 @@ omitted"* — and **rigc deliberately does the opposite** (R1, §2). Writing `x:
5606
5647
  legitimate here. The habit worth carrying over is not *omit defaults*, it is
5607
5648
  *declare only what the shot needs*.
5608
5649
 
5609
- ### 10.6 What this section does not claim
5650
+ ### 10.6 What a round trip gives back
5651
+
5652
+ Everything above is what the editor **does**. This is what it **returns** — which
5653
+ matters to you for one reason: a construct nobody has carried through the editor
5654
+ is a construct that might vanish there, and an agent cannot see that it did.
5655
+
5656
+ 🔬 The source is one run: eight discriminator rigs, each built to isolate a group
5657
+ of fields, compiled by rigc **0.21.0** (emitting 4.3.13), imported into a licensed
5658
+ Spine **4.3.26** through the documented CLI and exported back on 2026-09-16, with
5659
+ the predictions written down before anything was opened. **Seven of the eight came
5660
+ back differing from their build in three header fields and nothing else** —
5661
+ `hash` and `audio`, which the editor adds, and `spine`, which it stamps with its
5662
+ own version. The eighth is the path rig, and it is the last bullet here.
5663
+
5664
+ ⚠️ *"Nothing else"* is under two normalisations, both of which are the exporter
5665
+ being ordinary rather than the editor changing anything: **float spelling** (`48`
5666
+ comes back `48.0`) and **omitted defaults** — the export drops any field equal to
5667
+ its parser default, so the header loses `x: 0` and `y: 0`, a bone loses `x: 0`, and
5668
+ a key at t=0 loses its `"time": 0`. Every name-keyed object is also re-sorted, per
5669
+ §10.1. None of those is a loss of information, and each is worth knowing before
5670
+ you read a `diff`.
5671
+
5672
+ ⚠️ Read every line below as *this construct survived*, never as *this construct is
5673
+ recommended*. §10.1–§10.5 are the recommendations; this subsection is only the
5674
+ evidence that the format will carry what you write.
5675
+
5676
+ - 🔬 **A transform constraint survives whole.** 4.3's `source` plus its
5677
+ `properties` map — including a nested `to` with `offset`, `max` and `scale` —
5678
+ came back field for field, with `localSource`, `localTarget`, `additive`,
5679
+ `clamp`, `mixRotate` and `mixY` beside it.
5680
+ - 🔬 **The `transform` timeline survives** — the group shape that maps a
5681
+ constraint name straight to a key array (§4.10), with its per-key mixes and
5682
+ curves intact.
5683
+ - 🔬 **`shear`, `shearx` and `sheary` bone timelines survive**, paired and
5684
+ single-axis, with both channels of a paired key and all their curve control
5685
+ points.
5686
+ - 🔬 **A bone's setup `scaleX`, `scaleY`, `shearX`, `shearY` and `inherit`
5687
+ survive**, a non-default `inherit` included — `noScale`, `onlyTranslation` and
5688
+ `noRotationOrReflection` were all carried on one rig.
5689
+ - 🔬 ⭐ **A bone's `color` and `icon` survive** — 4.3's bone icons round-trip
5690
+ (`circle`, `ff7f00ff`). They are nonessential data, so this is also a reading of
5691
+ §10.5's caveat.
5692
+ - 🔬 **The `drawOrder` timeline survives, offsets and all — including the empty
5693
+ key.** A key with no `offsets` restores the setup order (§4.7), and it came back
5694
+ **empty** rather than spelled out as an identity permutation, which is the
5695
+ spelling that would have made every later diff read as a change.
5696
+ - 🔬 **Slot `color`, `dark` and `blend` survive**, `blend: multiply` and
5697
+ `blend: additive` included.
5698
+ - 🔬 **A path constraint's non-default modes survive**: `positionMode: fixed`,
5699
+ `spacingMode: proportional`, `rotateMode: chainScale`.
5700
+ - 🔬 **A path attachment's `closed: true` and `constantSpeed: false` survive**, and
5701
+ so do its `position`, `spacing` and three-channel `mix` timelines — all three
5702
+ channels of every `mix` key, and all twelve curve numbers on each key that
5703
+ carries a curve. ⚠️ Its `lengths` did **not**, which is the last bullet — and
5704
+ since [#560](https://github.com/firejune/rigc/issues/560) they do, because rigc
5705
+ now emits the numbers the editor recomputes rather than numbers near them.
5706
+ - 🔬 **`physics.mix` and `physics.reset` timelines survive**, the `reset` key
5707
+ included — a key that carries a time and no value at all — and so does the
5708
+ physics constraint's setup `mix`.
5709
+ - 🔬 **A time-driven slider survives** — one with no `bone`, carrying `loop`, a
5710
+ setup `time` and a setup `mix`, with both its `time` and `mix` timelines.
5711
+ - 🔬 **`boundingbox` and `clipping` attachments survive whole**: `vertexCount`,
5712
+ `vertices`, the clipping `end` slot, `convex`, and both editor colours.
5713
+ - 🔬 **An unweighted mesh survives and its `hull` is kept, not recomputed** — a
5714
+ five-vertex mesh declaring `hull: 4` came back `4`, with its `edges`, `color`,
5715
+ `uvs` and `triangles` unchanged. Beside it, on the same rig: the header's
5716
+ `referenceScale`, a region's `rotation` / `scaleX` / `scaleY` / `color`, and an
5717
+ attachment whose `path` differs from its placeholder (the mechanism of
5718
+ [#552](https://github.com/firejune/rigc/issues/552)) all survive. A `--pack`
5719
+ build round-trips too.
5720
+
5721
+ 🚨 **The one thing that did not come back is a path attachment's `lengths`, and it
5722
+ moved the drawing.** The editor recomputes them at export from the geometry —
5723
+ the imported project holds the numbers it was given — and it measures each curve
5724
+ with the **runtime's own four-sample forward difference**, not with an arbitrarily
5725
+ fine one. `PathConstraint`'s `constantSpeed` re-measure is that same computation:
5726
+ its constants are `0.1875 = 3t²`, `0.09375 = 6t³` and `(cx1 − x1) · 0.75 = 3t` at
5727
+ **t = 1/4**, four `Math.sqrt` terms per curve. A 4-sample chord sum over the same
5728
+ control points reproduces the editor to every digit it prints, on a closed path
5729
+ under 4.3.26 (`[152.7006, 305.4012, 458.1019, 610.8025]`) and on an open one under
5730
+ 4.3.23 (`[430.8389, 838.0142, 1127.736, …]`).
5731
+
5732
+ ⚠️ **That last reading settles the model and cannot settle the spelling.** A
5733
+ 4-sample chord sum agrees with the runtime's forward difference to about **nine
5734
+ significant digits** — *below* what float32 can hold, which is why both spellings
5735
+ reproduce both exports exactly, and *above* the six decimals rigc emits, which is
5736
+ why the file can tell them apart. On both rigs above they round apart on the
5737
+ **last** curve, where the running total has accumulated most: `610.802519` against
5738
+ `610.802520`, `1127.735817` against `1127.735818`. So the editor is the evidence
5739
+ for *what* is computed, and only `PathConstraint` itself is evidence for *how*.
5740
+
5741
+ ⭐ **rigc emits the forward difference itself** since
5742
+ [#560](https://github.com/firejune/rigc/issues/560) — `pathCurveLengths` in
5743
+ [`src/compile.ts`](../src/compile.ts) is those runtime lines transcribed, down to
5744
+ `Math.sqrt(dx * dx + dy * dy)` rather than `Math.hypot` and `0.16666667` rather
5745
+ than `1 / 6`, both of which change the emitted file. All seven entries of the two
5746
+ exports above now come back at the precision the editor prints them, so a path rig
5747
+ built here and one authored in the editor parameterise identically. ⚠️ The
5748
+ consequence for you is a vocabulary one: `lengths` is **not** an arc length. It
5749
+ sits about 0.5 % below the arc by construction, so a physical quantity — how far a
5750
+ wheel rolls, how long a ribbon is — has to be measured off the curve and not read
5751
+ out of the artifact.
5752
+
5753
+ 🔬 **And the editor always writes `vertexCount / 3` entries, computing the
5754
+ wrap-around curve even on an open path** — that open path's fourth entry,
5755
+ `2136.228`, is the closed-chain cumulative. ⚠️ Neither array is wrong: the parser
5756
+ allocates `vertexCount / 3` and copies whatever is there, and `PathConstraint`
5757
+ reads at most `lengths[curveCount]`, so the trailing entry is never read.
5758
+
5759
+ ⇒ **What this means for you.** `lengths` is the one number in a path rig you
5760
+ cannot check by looking: `diff` does not compare it, and `A33` asks only that it
5761
+ strictly increase, which any plausible array does. A total that is a fraction of a
5762
+ percent out was measured at **4.9612 mean MAE** on the one rig of that run with a
5763
+ path constraint, against **0.0000** on the other seven. If you are comparing a
5764
+ path rig against an editor reference and everything structural agrees while the
5765
+ picture does not, this is the first place to look.
5766
+
5767
+ ⚠️ **What decides whether it moves a pixel is the POSITION mode**, and an earlier
5768
+ reading of this paragraph put it on `spacingMode: proportional`, which is the one
5769
+ mode it cannot be: proportional spacing scales *with* the total, and that is
5770
+ exactly what cancels. The rig that drifted is `positionMode: fixed`, where an
5771
+ absolute `position` is compared against a total that moved. Under
5772
+ `positionMode: percent` the position scales with the total too, so a uniform
5773
+ change cancels out of both — measured across #560, `gallery/ride` is
5774
+ percent/percent and every one of its 74 rendered frames came back **byte
5775
+ identical** on an emitted array all three of whose numbers moved. ⇒ Read a
5776
+ `lengths` disagreement as *certainly wrong data, and visible only under
5777
+ `positionMode: fixed`*.
5778
+
5779
+ ### 10.7 What this section does not claim
5610
5780
 
5611
5781
  Conventions that are visible in reference exports but that **no public Spine page
5612
5782
  states** are deliberately absent. A guide that asserted them would be handing you an
@@ -5614,7 +5784,10 @@ answer read off the exports:
5614
5784
 
5615
5785
  - any figure for keys per second, or for how key density scales with frame rate;
5616
5786
  - which curve type any particular example project or studio actually shipped;
5617
- - whether a given export was made with Nonessential data checked;
5787
+ - whether a **corpus** export — one somebody else made, out of the editor's own
5788
+ dialog — was made with Nonessential data checked. ⚠️ §10.5 now answers this for
5789
+ the **CLI's** `-e json`, where it is measured; that measurement says nothing
5790
+ about an export you were handed, and the two must not be read as one;
5618
5791
  - how many bones, slots or timelines a rig of a given size ought to have;
5619
5792
  - whether a shipped rig prefers automatic Bezier handles or hand-placed ones.
5620
5793
 
@@ -597,7 +597,7 @@ with the member's own `skin: true` — either half alone is refused, because `Sk
597
597
  | `mesh` | 🟡 | emits `type`, `uvs`, `triangles`, `vertices` (**weighted encoding only**), `hull`, `width`, `height`, `edges` (`compile.ts:1343`, and part 4's rung-6 entry measures it byte-identical to the reference), `path`, `color`. ❌ `sequence`. Unweighted meshes are 🚫 **A20_MESH_WEIGHTS_COHERENT** (`validate.ts:430-433`) |
598
598
  | `linkedmesh` | ❌ | deliberately deferred — never appears in the corpus (part 3-1) |
599
599
  | `boundingbox` | ✅ | `vertexCount` (required and cross-checked), `vertices` **or** by-name `weights`, `color`. **A33_VERTEX_ATTACHMENT_GEOMETRY** |
600
- | `path` | ✅ | `vertexCount` (required, and checked as a multiple of 3 — the parser's own `vertexCount / 3` takes a fractional size in silence), `vertices` **or** by-name `weights`, `closed`, `constantSpeed`, `color`, and a **measured** `lengths`: the cumulative setup arc length of each curve, taken through each influence's own bone, refused if authored. **A33_VERTEX_ATTACHMENT_GEOMETRY** re-checks the structure and the array's monotonicity. ❌ a `deform` timeline on one |
600
+ | `path` | ✅ | `vertexCount` (required, and checked as a multiple of 3 — the parser's own `vertexCount / 3` takes a fractional size in silence), `vertices` **or** by-name `weights`, `closed`, `constantSpeed`, `color`, and a **measured** `lengths`: the cumulative setup length at the end of each curve, taken through each influence's own bone and measured as `PathConstraint` measures it — its own four-sample forward difference, which is what the editor writes too and is about 0.5 % below the arc (AUTHORING §10.6) — refused if authored. **A33_VERTEX_ATTACHMENT_GEOMETRY** re-checks the structure and the array's monotonicity. ❌ a `deform` timeline on one |
601
601
  | `point` | ❌ | deliberately deferred — never appears in the corpus (part 3-1) |
602
602
  | `clipping` | ✅ under `--profile spine` · 🚫 under `spine-html` | `end` (refused when it names no slot), `convex`, `inverse`, `vertexCount`, geometry, `color`. **A33**, and **A11_NO_CLIPPING_ATTACHMENTS** is the renderer-profile refusal |
603
603
  | `sequence` block | ❌ | |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "0.22.0",
3
+ "version": "0.22.1",
4
4
  "description": "Rig compiler for Spine — declarative rig specs in, Spine 4.3 skeleton data out, verified by a spine-core round-trip. Built so AI agents can author rigs and check their own work; the output imports into the Spine editor.",
5
5
  "type": "module",
6
6
  "bin": {
package/src/compile.ts CHANGED
@@ -2888,19 +2888,6 @@ function setupWorldVertices(
2888
2888
  return out;
2889
2889
  }
2890
2890
 
2891
- /**
2892
- * How many samples per curve the arc-length measurement takes.
2893
- *
2894
- * 64 is a choice about accuracy, and the accuracy that matters is against the
2895
- * runtime rather than against calculus: `PathConstraint` re-measures a
2896
- * `constantSpeed` path with a **4-sample** forward difference per curve, so the
2897
- * number here only has to be fine enough that the two agree to well inside the
2898
- * tolerance anything downstream compares at. It is a constant rather than a
2899
- * parameter because a per-call sample count would make `lengths` depend on the
2900
- * caller, and A18 compares two emits byte for byte.
2901
- */
2902
- const PATH_LENGTH_SAMPLES = 64;
2903
-
2904
2891
  /**
2905
2892
  * The knot-and-handle chain a path attachment's vertices actually form, in the
2906
2893
  * runtime's own order (`PathConstraint.computeWorldPositions`).
@@ -2919,11 +2906,43 @@ function pathChain(points: Array<[number, number]>, closed: boolean): Array<[num
2919
2906
  }
2920
2907
 
2921
2908
  /**
2922
- * Cumulative arc length at the end of each curve of the chain, in world units.
2909
+ * Cumulative arc length at the end of each curve of the chain, in world units —
2910
+ * **the runtime's own measurement, restated line for line**.
2923
2911
  *
2924
2912
  * One entry per curve, which is what `lengths[curve]` indexes: the parser walks
2925
2913
  * curves with `if (p > lengths[curve]) continue`, and reads `lengths[curveCount]`
2926
2914
  * — where `curveCount` is the LAST curve's index — as the total path length.
2915
+ *
2916
+ * ⭐ **What this is not: an approximation of the arc length.** `lengths` is not a
2917
+ * fact about the Bezier, it is the number the consumer of the field computes for
2918
+ * itself when it is not given one. `PathConstraint.computeWorldPositions`
2919
+ * (`PathConstraint.js:289-324` in `@esotericsoftware/spine-core` 4.3.13) measures
2920
+ * a `constantSpeed` path with a cubic **forward difference** taken at `t = 1/4`
2921
+ * — `0.1875 = 3t²`, `0.09375 = 6t³`, `0.75 = 3t`, `0.16666667` standing in for
2922
+ * 1/6 — accumulating four `Math.sqrt` terms per curve into a running
2923
+ * `pathLength`, and writing the running value into `curves[i]` at each curve's
2924
+ * end. The Spine editor's exported `lengths` are that same computation: measured
2925
+ * against two editor exports, one open path from 4.3.23 and one closed path from
2926
+ * 4.3.26, this reproduces every digit the editor printed. So the loop below is a
2927
+ * transcription, not a sampler that happens to agree — and the two are not the
2928
+ * same thing, which is the reason the transcription is here.
2929
+ *
2930
+ * ⚠️ rigc measured this with a 64-chord sum until issue #560, and the comment
2931
+ * that stood here argued the difference was inside anything's tolerance. It was
2932
+ * not: 0.70 % high, uniformly, worth **4.96 px mean MAE** on the round trip of a
2933
+ * rig whose path constraint reads the field. A 4-chord sum is *also* not the
2934
+ * repair — it agrees with the forward difference only to about nine significant
2935
+ * digits, which is below what float32 can hold (so no editor export can tell the
2936
+ * two apart) and above rigc's own six-decimal rounding (so the emitted file can):
2937
+ * on both measured rigs the two spellings differ in `r6` on the LAST curve, where
2938
+ * the accumulated difference is largest. `PS67`–`PS69` in `selftest.ts` compare
2939
+ * this against `PathConstraint`'s own `curves` array read off a posed skeleton,
2940
+ * which is the only oracle that can see that gap.
2941
+ *
2942
+ * 🔒 The transcription is deliberate down to the spelling: `Math.sqrt(dx * dx +
2943
+ * dy * dy)` rather than `Math.hypot`, `0.16666667` rather than `1 / 6`, and the
2944
+ * running total carried across curves rather than restarted. Each of those is a
2945
+ * place where a more accurate line would emit a different file.
2927
2946
  */
2928
2947
  function pathCurveLengths(chain: Array<[number, number]>): number[] {
2929
2948
  const out: number[] = [];
@@ -2933,21 +2952,26 @@ function pathCurveLengths(chain: Array<[number, number]>): number[] {
2933
2952
  const [cx1, cy1] = chain[c + 1];
2934
2953
  const [cx2, cy2] = chain[c + 2];
2935
2954
  const [x2, y2] = chain[c + 3];
2936
- let px = x1;
2937
- let py = y1;
2938
- for (let s = 1; s <= PATH_LENGTH_SAMPLES; s++) {
2939
- const t = s / PATH_LENGTH_SAMPLES;
2940
- const u = 1 - t;
2941
- const a = u * u * u;
2942
- const b = 3 * u * u * t;
2943
- const d = 3 * u * t * t;
2944
- const e = t * t * t;
2945
- const x = a * x1 + b * cx1 + d * cx2 + e * x2;
2946
- const y = a * y1 + b * cy1 + d * cy2 + e * y2;
2947
- total += Math.hypot(x - px, y - py);
2948
- px = x;
2949
- py = y;
2950
- }
2955
+ const tmpx = (x1 - cx1 * 2 + cx2) * 0.1875;
2956
+ const tmpy = (y1 - cy1 * 2 + cy2) * 0.1875;
2957
+ const dddfx = ((cx1 - cx2) * 3 - x1 + x2) * 0.09375;
2958
+ const dddfy = ((cy1 - cy2) * 3 - y1 + y2) * 0.09375;
2959
+ let ddfx = tmpx * 2 + dddfx;
2960
+ let ddfy = tmpy * 2 + dddfy;
2961
+ let dfx = (cx1 - x1) * 0.75 + tmpx + dddfx * 0.16666667;
2962
+ let dfy = (cy1 - y1) * 0.75 + tmpy + dddfy * 0.16666667;
2963
+ total += Math.sqrt(dfx * dfx + dfy * dfy);
2964
+ dfx += ddfx;
2965
+ dfy += ddfy;
2966
+ ddfx += dddfx;
2967
+ ddfy += dddfy;
2968
+ total += Math.sqrt(dfx * dfx + dfy * dfy);
2969
+ dfx += ddfx;
2970
+ dfy += ddfy;
2971
+ total += Math.sqrt(dfx * dfx + dfy * dfy);
2972
+ dfx += ddfx + dddfx;
2973
+ dfy += ddfy + dddfy;
2974
+ total += Math.sqrt(dfx * dfx + dfy * dfy);
2951
2975
  out.push(total);
2952
2976
  }
2953
2977
  return out;
package/src/rig.ts CHANGED
@@ -676,12 +676,18 @@ export interface RigClippingAttachment extends RigVertexGeometry {
676
676
  * six then straddle the knots, and the constraint slides bones along a curve
677
677
  * nobody drew.
678
678
  *
679
- * ⚠️ `lengths` is NOT authored here. It is the cumulative arc length at the end
680
- * of each curve in the SETUP pose, in world units — a measurement of the
681
- * geometry above, and the same relationship `image` has to `width`/`height`: a
682
- * restated number can disagree with the vertices, and when it does, a
679
+ * ⚠️ `lengths` is NOT authored here. It is the cumulative length at the end of
680
+ * each curve in the SETUP pose, in world units — a measurement of the geometry
681
+ * above, and the same relationship `image` has to `width`/`height`: a restated
682
+ * number can disagree with the vertices, and when it does, a
683
683
  * `constantSpeed: false` path traverses a length that is not the length of the
684
684
  * curve, silently. So rigc measures it and refuses an authored one by name.
685
+ *
686
+ * 🔸 *Which* length, exactly, is `SpinePathAttachment`'s subject in
687
+ * [`types.ts`](types.ts) and it is not the arc: it is `PathConstraint`'s own
688
+ * four-sample forward difference, about 0.5 % below the arc, which is what the
689
+ * Spine editor writes back too (issue #560). This comment said "arc length"
690
+ * until then.
685
691
  */
686
692
  export interface RigPathAttachment extends RigVertexGeometry {
687
693
  type: 'path';
package/src/types.ts CHANGED
@@ -804,12 +804,80 @@ export interface SpineClippingAttachment {
804
804
  /**
805
805
  * A composite cubic Bezier, for a path constraint to slide bones along.
806
806
  *
807
- * `lengths` is the cumulative arc length at the end of each curve in the setup
808
- * pose — one entry per curve, so `vertexCount / 3 - 1` of them on an open path
809
- * and `vertexCount / 3` on a closed one. It has no parser default and the parser
807
+ * `lengths` is the cumulative length at the end of each curve in the setup pose,
808
+ * measured **the way `PathConstraint` measures it** — a four-sample forward
809
+ * difference per curve (`PathConstraint.js:301-320`), which is also what the
810
+ * Spine editor exports and which reads about **0.5 % below the true arc**. One
811
+ * entry per curve, so `vertexCount / 3 - 1` of them on an open path and
812
+ * `vertexCount / 3` on a closed one. It has no parser default and the parser
810
813
  * dereferences `map.lengths.length` unconditionally, so an absent array is one of
811
814
  * the format's few loud failures; rigc measures the numbers off the geometry
812
815
  * rather than letting a spec restate them.
816
+ *
817
+ * ⚠️ That sentence read *"the cumulative **arc** length"* until issue #560, and
818
+ * the word was load-bearing in the wrong direction: this is not an arc length,
819
+ * and no refinement of the integral converges on it. It is the number the
820
+ * field's own consumer computes when it is not given one. ⇒ Do not derive a
821
+ * physical quantity from it — how far a wheel rolls, how long a ribbon is.
822
+ * `position` is stated against it; arc length is not it.
823
+ *
824
+ * ⚠️ **The EDITOR writes `vertexCount / 3` entries on BOTH — measured**
825
+ * (round trip 6, 2026-09-16, Spine 4.3.26). It computes the wrap-around curve
826
+ * even for an open path: `gallery/ride`'s 12-vertex open path came back with
827
+ * **four** entries on 2026-09-04 (4.3.23) and the fourth, `2136.228`, is the
828
+ * *closed*-chain cumulative. ⚠️ Neither array is wrong, and this is not a case
829
+ * of the editor knowing something rigc does not. `SkeletonJson.js:601` allocates
830
+ * `Utils.newArray(vertexCount / 3, 0)` and copies whatever is there, while
831
+ * `PathConstraint` reads at most `lengths[curveCount]` with
832
+ * `curveCount = verticesLength / 6 − (closed ? 1 : 2)` — index 2 on that path.
833
+ * The trailing entry the editor adds to an open path is never read by anything.
834
+ *
835
+ * 🚨 **What the editor writes INTO those entries is its own measurement, and it
836
+ * is the runtime's, not calculus'** (issue #560, measured on the same trip).
837
+ * `PathConstraint`'s `constantSpeed` re-measure is a four-sample forward
838
+ * difference per curve — its constants are `0.1875 = 3t²`, `0.09375 = 6t³` and
839
+ * `(cx1 − x1) · 0.75 = 3t` at **t = 1/4**, accumulating four `Math.sqrt` terms —
840
+ * and the editor's stored `lengths` are that same computation. A 4-sample chord
841
+ * sum over the same control points reproduces the editor to every digit it
842
+ * prints, on two rigs and two editor builds: `pathmodes` (closed, 4.3.26) came
843
+ * back `[152.7006, 305.4012, 458.1019, 610.8025]` and `ride` (open, 4.3.23)
844
+ * `[430.8389, 838.0142, 1127.736, …]`. It is a recomputation at **export** — the
845
+ * inflated `.spine` project holds the imported numbers verbatim — so a path rig
846
+ * is re-parameterised by the trip rather than corrupted by it.
847
+ *
848
+ * ⇒ **rigc emits that computation, not a sampler aimed at it** (issue #560).
849
+ * `pathCurveLengths` in [`compile.ts`](compile.ts) is `PathConstraint.js:301-320`
850
+ * transcribed, down to `Math.sqrt(dx * dx + dy * dy)` rather than `Math.hypot`
851
+ * and `0.16666667` rather than `1 / 6`; `PS67`–`PS69` in `selftest.ts` hold it
852
+ * there by requiring it to reproduce a real `PathConstraint.curves` array **bit
853
+ * for bit** on the runtime's own posed chain. Measured after the change, all
854
+ * seven entries of both editor exports above come back at the precision the
855
+ * editor prints them.
856
+ *
857
+ * ⚠️ This paragraph used to point at a constant — `PATH_LENGTH_SAMPLES` — and ask
858
+ * *how finely rigc should sample*. There is no such constant now, and the
859
+ * question was the wrong one. The chord-sum reading above is true and it is not
860
+ * sufficient: a 4-sample chord sum agrees with the forward difference to about
861
+ * **nine significant digits**, which is *below* what float32 can hold — so no
862
+ * editor export can tell the two apart, and that reading can only settle the
863
+ * MODEL — and *above* rigc's six-decimal rounding, so the emitted file can. On
864
+ * both rigs above the two spellings round apart on the **last** curve, where the
865
+ * running total has accumulated most: `610.802519` against `610.802520`, and
866
+ * `1127.735817` against `1127.735818`. So the editor is the evidence for what is
867
+ * being computed and only the runtime is evidence for how.
868
+ *
869
+ * 📌 For the record of what the disagreement cost when it was found: the build
870
+ * rigc **0.21.0** emitted for `pathmodes` sat a uniform **0.70 %** above the
871
+ * editor's four numbers, and `check` read **4.9612 mean MAE** against 0.0000 on
872
+ * the seven rigs of that run without a path. ⚠️ What decides whether that moves a
873
+ * pixel is the POSITION mode, not the spacing mode: `pathmodes` is
874
+ * `positionMode: fixed`, where an absolute `position` is compared against a total
875
+ * that scaled, so the bone slides. Under `positionMode: percent` a uniform scale
876
+ * cancels out of both the position and the spacing — `gallery/ride` is
877
+ * percent/percent and every one of its 74 rendered frames came back **byte
878
+ * identical** across this change, on an emitted array all three of whose numbers
879
+ * moved. `A33_VERTEX_ATTACHMENT_GEOMETRY` asks only that the array strictly
880
+ * increase, which both arrays do, and `diff` does not compare it at all.
813
881
  */
814
882
  export interface SpinePathAttachment {
815
883
  type: 'path';
@@ -932,6 +1000,28 @@ export interface SpineSkeletonJson {
932
1000
  * four-skin rig on import without a word: that refusal was rigc's own
933
1001
  * harness discarding the editor's stderr, and the editor had named the
934
1002
  * cause all along.
1003
+ *
1004
+ * - ✅ **What round trip 6 added to the `constraints` line is TYPES, not
1005
+ * order** (2026-09-16, Spine 4.3.26, eight rigs). The three that stood here
1006
+ * were `gallery/look`'s, and they are two types: `yaw` and `tilt`
1007
+ * (**slider**) and `whip` (**physics**). The trip carried a `transform`
1008
+ * with its whole 4.3 `source` + `properties` map, a `path` with three
1009
+ * non-default modes, another `physics` and another `slider`, and every one
1010
+ * came back field for field — so the array is now measured over **four**
1011
+ * of the format's types. `ik` is in none of the rigs anybody has
1012
+ * round-tripped, which is why the count is four and not five.
1013
+ *
1014
+ * ⚠️ **None of round 6's own constraint arrays can tell order preserved
1015
+ * from a name sort, and reading them as if they could would be #537's
1016
+ * mistake in a second collection.** The three rigs that carry constraints
1017
+ * hold `aim, hold` (xform), `hold, knob` (physlider) and `ride` alone
1018
+ * (pathmodes). All three came back in the order they were given — and all
1019
+ * three were *already* in name order, so a re-sort and a preservation are
1020
+ * the same picture there, exactly as a one-element array is for `skins`
1021
+ * above. ⇒ The order claim still rests entirely on `gallery/look`, whose
1022
+ * build order `yaw, tilt, whip` is **not** name order (`tilt < whip < yaw`)
1023
+ * and which #539 read back unchanged. That one rig is load-bearing and
1024
+ * nothing in this tree re-takes it.
935
1025
  */
936
1026
  events?: Record<string, SpineEvent>;
937
1027
  animations: Record<
@@ -384,7 +384,7 @@ function checkFigures(reportPath: string): string[] {
384
384
  }
385
385
 
386
386
  /** The shape of a skeleton file, for the field-by-field comparison. */
387
- interface Shape {
387
+ export interface Shape {
388
388
  spine: string;
389
389
  bones: number;
390
390
  slots: number;
@@ -395,16 +395,43 @@ interface Shape {
395
395
  images: string | null;
396
396
  }
397
397
 
398
- function shapeOf(path: string): Shape {
398
+ /**
399
+ * How many constraints of each `type`, read out of 4.3's single array.
400
+ *
401
+ * 🚨 This used to count four FIXED keys off the 4.1-era top-level arrays —
402
+ * `d.ik`, `d.transform`, `d.path`, `d.physics` — none of which a 4.3 file has
403
+ * (issue #561). Every row therefore read `0 -> 0` on every trip this tool has
404
+ * ever run, so the summary's answer to *"did the editor drop a constraint"* was
405
+ * a constant, printed with the same confidence as the rows that measure
406
+ * something. Measured on `round6/out/pathmodes/build/skeleton.json`, whose
407
+ * top-level keys are `skeleton, bones, slots, skins, animations, constraints`:
408
+ * the old reads returned `{ik: 0, transform: 0, path: 0, physics: 0}` beside one
409
+ * path constraint named `ride`.
410
+ *
411
+ * ⭐ The keys are now the types actually **present**, which is why `shapeDiff`
412
+ * unions them: a type that vanishes has a key on one side only, and a fixed key
413
+ * list would have to be kept by hand against a format that added `slider` in
414
+ * 4.3 and can add another.
415
+ */
416
+ function constraintsByType(list: ReadonlyArray<{ type?: unknown }>): Record<string, number> {
417
+ const counts: Record<string, number> = {};
418
+ for (const c of list) {
419
+ // A constraint with no usable `type` is what `A01` exists to catch, so it is
420
+ // counted under a name rather than dropped — a row nobody can read beats a
421
+ // row nobody gets.
422
+ const type = typeof c?.type === 'string' && c.type !== '' ? c.type : '(no type)';
423
+ counts[type] = (counts[type] ?? 0) + 1;
424
+ }
425
+ return counts;
426
+ }
427
+
428
+ export function shapeOf(path: string): Shape {
399
429
  interface Skel {
400
430
  skeleton?: { spine?: string; images?: string };
401
431
  bones?: unknown[];
402
432
  slots?: unknown[];
403
433
  skins?: Array<{ attachments?: Record<string, Record<string, unknown>> }>;
404
- ik?: unknown[];
405
- transform?: unknown[];
406
- path?: unknown[];
407
- physics?: unknown[];
434
+ constraints?: Array<{ type?: unknown }>;
408
435
  animations?: Record<string, Record<string, unknown>>;
409
436
  }
410
437
  const d = JSON.parse(readFileSync(path, 'utf8')) as Skel;
@@ -418,12 +445,7 @@ function shapeOf(path: string): Shape {
418
445
  (n, s) => n + Object.values(s.attachments ?? {}).reduce((m, v) => m + Object.keys(v).length, 0),
419
446
  0,
420
447
  ),
421
- constraints: {
422
- ik: (d.ik ?? []).length,
423
- transform: (d.transform ?? []).length,
424
- path: (d.path ?? []).length,
425
- physics: (d.physics ?? []).length,
426
- },
448
+ constraints: constraintsByType(d.constraints ?? []),
427
449
  animations: Object.keys(d.animations ?? {}).sort(),
428
450
  timelineKinds: [...kinds].sort(),
429
451
  images: d.skeleton?.images ?? null,
@@ -431,7 +453,7 @@ function shapeOf(path: string): Shape {
431
453
  }
432
454
 
433
455
  /** The rows where the two shapes disagree — what the editor rewrote. */
434
- function shapeDiff(before: Shape, after: Shape): string[] {
456
+ export function shapeDiff(before: Shape, after: Shape): string[] {
435
457
  const rows: string[] = [];
436
458
  const cmp = (field: string, a: unknown, b: unknown): void => {
437
459
  const x = JSON.stringify(a);
@@ -443,7 +465,13 @@ function shapeDiff(before: Shape, after: Shape): string[] {
443
465
  cmp('bones', before.bones, after.bones);
444
466
  cmp('slots', before.slots, after.slots);
445
467
  cmp('attachments', before.attachments, after.attachments);
446
- for (const k of Object.keys(before.constraints)) cmp(`${k} constraints`, before.constraints[k], after.constraints[k]);
468
+ // The UNION of both sides' types, not the build's: a constraint type the build
469
+ // has and the export does not is the case this row exists for, and iterating
470
+ // one side's keys would also miss a type only the export carries. Absent reads
471
+ // as 0 so the row says `build 1 -> export 0` rather than naming `undefined`.
472
+ for (const k of [...new Set([...Object.keys(before.constraints), ...Object.keys(after.constraints)])].sort()) {
473
+ cmp(`${k} constraints`, before.constraints[k] ?? 0, after.constraints[k] ?? 0);
474
+ }
447
475
  cmp('animations', before.animations, after.animations);
448
476
  cmp('timeline kinds', before.timelineKinds, after.timelineKinds);
449
477
  return rows;
@@ -489,6 +517,82 @@ function main(): void {
489
517
 
490
518
  if (!existsSync(source)) fail(`no skeleton.json in the build directory ${opts.build}`);
491
519
 
520
+ // 🚨 The art the EDITOR will look for, checked before the editor is started —
521
+ // issue #562. The editor's JSON import reads `skeleton.images` and finds each
522
+ // attachment's file by name under it; it never reads an atlas (#370, measured
523
+ // at the MISSING wall). So a build whose `images` no longer resolves imports
524
+ // as a skeleton with no pixels, and the run goes on to gate, `diff`, render
525
+ // and `check` a candidate whose every region is blank — a green-looking
526
+ // measurement of nothing, or a red one blaming the editor.
527
+ //
528
+ // ⚠️ This is a `--pack` build's ordinary shape, not a corner case. `--pack`
529
+ // writes ONE shared page into `--out` and no loose parts at all (measured:
530
+ // `round6/out/packed/build/` holds `skeleton.json`, `skeleton.atlas`,
531
+ // `skeleton.png` and nothing else), so `skeletonImagesPath` falls through to
532
+ // the loose parts directory and `images` necessarily points OUT of the build
533
+ // — `"../../../rigs/packed/parts/"` on that run. The build is self-contained
534
+ // for a runtime and is not for the editor, and the two directories go their
535
+ // separate ways the moment anybody moves either.
536
+ //
537
+ // 🔒 Why this refuses rather than pointing `images` inside `--out`: there is
538
+ // nothing in there to point AT. The editor would look for `crown.png` beside
539
+ // the skeleton and find one packed page, so the "fix" turns a path that
540
+ // resolves while the parts are in place into one that can never resolve at
541
+ // all. What the editor needs is loose files, which is what `--copy-images`
542
+ // makes — and `--pack --copy-images` is refused by `cli.ts`, correctly,
543
+ // because a packed atlas does not reference loose parts.
544
+ //
545
+ // ⛔ It is checked HERE, before step 1, rather than beside the atlas check
546
+ // further down, because each precondition sits before the step it protects:
547
+ // the atlas's page names matter to the harness's own copy in step 3, and this
548
+ // matters to the editor's import in step 1.
549
+ {
550
+ interface ImagesProbe {
551
+ skeleton?: { images?: string };
552
+ skins?: Array<{ attachments?: Record<string, Record<string, { type?: string; path?: string }>> }>;
553
+ }
554
+ const probe = JSON.parse(readFileSync(source, 'utf8')) as ImagesProbe;
555
+ const declared = probe.skeleton?.images;
556
+ if (declared !== undefined && declared !== '') {
557
+ const imagesDir = resolve(opts.build, declared);
558
+ // Only the two attachment types that read a texture. A boundingbox,
559
+ // clipping or path attachment names no image and would be a false
560
+ // refusal — `src/types.ts` says so for each of them.
561
+ const wanted = new Set<string>();
562
+ for (const skin of probe.skins ?? []) {
563
+ for (const slot of Object.values(skin.attachments ?? {})) {
564
+ for (const [placeholder, att] of Object.entries(slot)) {
565
+ if (att.type !== undefined && att.type !== 'mesh') continue;
566
+ wanted.add(att.path ?? placeholder);
567
+ }
568
+ }
569
+ }
570
+ const EXTENSIONS = ['.png', '.jpg', '.jpeg'];
571
+ const missing = [...wanted]
572
+ .sort()
573
+ .filter((name) => !EXTENSIONS.some((ext) => existsSync(join(imagesDir, `${name}${ext}`))));
574
+ if (!existsSync(imagesDir)) {
575
+ fail(
576
+ `the build's skeleton.images is "${declared}", which resolves to ${imagesDir} — and there is no ` +
577
+ 'directory there. The editor finds a JSON import\'s art by that path and never by the atlas, so the ' +
578
+ 'import would produce a skeleton with no pixels and every measurement after it would be of nothing. ' +
579
+ 'A `--pack` build is a runtime artifact: its pages are in --out and its `images` names the loose parts ' +
580
+ 'it was packed from, so it round-trips only while those parts are where they were at build time. ' +
581
+ 'Rebuild with `--copy-images` (which puts the parts beside the skeleton), or put that directory back.',
582
+ );
583
+ }
584
+ if (missing.length > 0) {
585
+ fail(
586
+ `the build's skeleton.images is "${declared}" (${imagesDir}) and ${missing.length} of ${wanted.size} ` +
587
+ `attachment image(s) are not under it — the first is "${missing[0]}". The editor resolves each ` +
588
+ 'attachment by name against that directory and never through the atlas, so those attachments would ' +
589
+ 'import with no pixels. If this is a `--pack` build, its one packed page is in --out and its parts are ' +
590
+ 'not: rebuild with `--copy-images`, which is the shape whose art travels with the skeleton.',
591
+ );
592
+ }
593
+ }
594
+ }
595
+
492
596
  rmSync(opts.out, { recursive: true, force: true });
493
597
  mkdirSync(join(opts.out, 'export'), { recursive: true });
494
598
  mkdirSync(join(opts.out, 'export-cand'), { recursive: true });
@@ -641,4 +745,11 @@ function main(): void {
641
745
  process.exit(gate.status === 0 && check.status === 0 ? 0 : 1);
642
746
  }
643
747
 
644
- main();
748
+ // ⭐ Guarded so `shapeOf` and `shapeDiff` can be READ by a control that has no
749
+ // editor (issue #561). Every other `ERT` case drives this file as a subprocess,
750
+ // which is the right shape for a refusal; step 6's summary is a pure function of
751
+ // two skeleton files, and driving an editor — or four rigc subcommands — to
752
+ // reach it would be paying for a round trip to test arithmetic. Run as a
753
+ // program this is unchanged: `bun tools/editor_roundtrip.ts …` makes this module
754
+ // the entry, so `import.meta.main` is true.
755
+ if (import.meta.main) main();