spine-rigc 2.51.0 → 2.52.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/NOTICE.md CHANGED
@@ -40,8 +40,8 @@ Software's terms, not a licence term of this project:
40
40
  development dependency for its own gate, and so does an install with
41
41
  `spine-core` added beside it. Validation is not optional on either: the gate
42
42
  runs through the runtime where it is installed and through rigc's own validator
43
- where it is not, and the two are held to the same verdicts in CI on 48 of the
44
- 49 assertions — the official parser's own parse runs only where the runtime is.
43
+ where it is not, and the two are held to the same verdicts in CI on 49 of the
44
+ 50 assertions — the official parser's own parse runs only where the runtime is.
45
45
 
46
46
  > **If a product integrates a Spine Runtime to play rigc's output, that integration
47
47
  > is subject to the terms above** — rigc neither creates those terms nor removes
package/README.md CHANGED
@@ -65,12 +65,12 @@ it cuts both ways:
65
65
  is a development dependency: this repository uses it to hold rigc's own validator
66
66
  to the round trip's verdicts — in CI, on the public example recipes and every
67
67
  selftest call, and on a private corpus of production rigs measured before each
68
- release. Of the 49 assertions, 48 run on both; `A00_ROUNDTRIP_PARSE`, the official
68
+ release. Of the 50 assertions, 49 run on both; `A00_ROUNDTRIP_PARSE`, the official
69
69
  parser's own parse, runs only where the runtime is installed and is reported as a
70
70
  SKIP elsewhere. There, in the parse's place, the report also carries the model
71
71
  side's own two parse rules, `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES`,
72
72
  which no round-trip rule matches and the registry does not hold — so its summary
73
- totals 51 on the core entry, and its last line counts 42 rules on the model side
73
+ totals 52 on the core entry, and its last line counts 42 rules on the model side
74
74
  ([AUTHORING §5.2](docs/AUTHORING.md) has a row for each). Install it beside rigc
75
75
  (`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs the round
76
76
  trip on every build. The
@@ -618,9 +618,9 @@ first three work on any reference you have, and `bench` is a repository workflow
618
618
  and `bun run fetch-examples`. The reasoning behind them is in
619
619
  [the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md).
620
620
 
621
- `build` and `validate` both default to `--profile spine` — the 35 validity rules, which
621
+ `build` and `validate` both default to `--profile spine` — the 36 validity rules, which
622
622
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile rig-play`
623
- adds all 49: the other 14 are one renderer's policy and one canvas budget's, and they
623
+ adds all 50: the other 14 are one renderer's policy and one canvas budget's, and they
624
624
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
625
625
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
626
626
  yourself: author under `--profile rig-play` and read the extra 14 as findings, and
@@ -764,7 +764,12 @@ and so is a build with no atlas or an atlas that is a directory, or an
764
764
  `--exported` file that is not one JSON object.
765
765
 
766
766
  ⛔ **Run the round trip by hand, on a machine that has the editor.** CI has no
767
- editor, so no automated check runs it.
767
+ editor, so no automated check runs it. The by-hand measurement before a landing
768
+ trips the production rigs and one **parts-built** rig, the exam's task-01
769
+ painting through `@rig-c/parts`. The production rigs were authored in the editor,
770
+ so they are already in the editor's own shape. A refusal that only a generated
771
+ rig carries cannot show up on them. Issue #1407 was such a refusal: a green build
772
+ whose meshes listed their bones in two orders, which the editor would not import.
768
773
 
769
774
  ⚠️ Build with `--copy-images`. An ordinary build's atlas names its pages by a
770
775
  relative path back to the art directory, and the round trip copies that atlas to
@@ -782,7 +787,7 @@ letting `A17` blame the editor for the harness's own doing.
782
787
  | 📥 **[docs/INGEST.md](docs/INGEST.md)** | **working with a skeleton you did not author.** What every command can and cannot do with a foreign `skeleton.json`, reading it with the toolchain, transcription as the route that makes it yours, what each validator complaint means on an export, and the re-pivot/rename/extend recipes. Ships in the package too |
783
788
  | 🤖 **[docs/PROMPTING.md](docs/PROMPTING.md)** | **handing the authoring to an AI agent** — the prompt clauses a measured pilot run paid for, and what you can leave unsaid. Ships in the package too |
784
789
  | 🔬 **[docs/SPEC_COVERAGE.md](docs/SPEC_COVERAGE.md)** | **the Spine 4.3 format, field by field.** Every key the 4.3 JSON parser and atlas reader take, what each defaults to, and the parser line it comes from — the rows rigc's refusals cite by part number. Ships in the package too |
785
- | 🎓 **[the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 49 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
790
+ | 🎓 **[the benchmark dossier](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md)** | **why you can trust the output.** The yardstick, `diff` and `check` and what neither can see, the eight-rung ladder and the spineboy graduation exam, the run viewer, the 50 named assertions with their profiles, and the selftest that has watched every one of them fire. Repository material — it is not in the npm package |
786
791
  | 📋 [LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/rig-c/core/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/rig-c/core/blob/main/docs/PILOT.md) | the live rung ledger, the clause statements a candidate is graded against, and how to run an agent through the ladder and score what comes back |
787
792
  | 🔬 [SURVEY_2026-08-22.md](https://github.com/rig-c/core/blob/main/docs/SURVEY_2026-08-22.md) | the survey the ladder was climbed by: rigc and the official examples as measured on 2026-08-22, and the gap list ordered by rung. A dated record, not kept current. Repository material |
788
793
  | 🧬 [GENERATIONS.md](https://github.com/rig-c/core/blob/main/docs/GENERATIONS.md) | **Spine data from another generation.** Why a 3.8–4.2 file read as 4.3 fails in silence, the policy that follows (detect from `skeleton.spine`, never guess, play on the matching runtime), what `A16` and `ingest` do with such a file, and the editor as the migration path. Repository material |
@@ -832,7 +837,7 @@ quality."* All six, with their verdicts, are in
832
837
  [docs/LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md).
833
838
 
834
839
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
835
- see, every rung, the run viewer, the 49 assertions and the selftest behind them — is
840
+ see, every rung, the run viewer, the 50 assertions and the selftest behind them — is
836
841
  [docs/BENCHMARK.md](https://github.com/rig-c/core/blob/main/docs/BENCHMARK.md).
837
842
  Live rung status is
838
843
  [docs/LADDER.md](https://github.com/rig-c/core/blob/main/docs/LADDER.md).
package/docs/AUTHORING.md CHANGED
@@ -165,7 +165,7 @@ side over the document — its own two parse rules first, `A00_MODEL_READ` and
165
165
  `A00_MODEL_REGIONS_ON_PAGES`, which stand where spine-core's parse stands and are
166
166
  not in the registry, then the registry's rules that read the model — and the round
167
167
  trip's own rules restated over the text the emitter wrote (A01, A02, A05, A07, A16,
168
- A31, A35, and A18 over a second compile's three files), and it does not run `A00_ROUNDTRIP_PARSE`, which reports **SKIP**
168
+ A31, A35, A52, and A18 over a second compile's three files), and it does not run `A00_ROUNDTRIP_PARSE`, which reports **SKIP**
169
169
  naming spine-core. Its report ends on a line saying which rules ran there and
170
170
  which did not, and it writes only when none of them fails (§5.2).
171
171
 
@@ -239,7 +239,7 @@ What the flags mean:
239
239
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
240
240
  | `--cut` | `build`, `explain` and `validate`: look up a named cut in `--cuts <cuts.json>`, **instead of** `--rig`/`--motion`/`--out` — the two spellings are one build stated two ways and are refused together. A `cuts.json` is `{ "<name>": { "rig": …, "motion": …, "out": …, "manifest"?: … } }`, every path in it relative to the table's own file, so the table lives with the project that owns the art |
241
241
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
242
- | `--profile` | `spine` = the 35 validity rules (**the default**) · `rig-play` = all 49, opt-in. `spine-html`, the profile's former name, is read as `rig-play` until the next minor release and prints a deprecation line on stderr naming `rig-play` |
242
+ | `--profile` | `spine` = the 36 validity rules (**the default**) · `rig-play` = all 50, opt-in. `spine-html`, the profile's former name, is read as `rig-play` until the next minor release and prints a deprecation line on stderr naming `rig-play` |
243
243
  | `--report` | `build` and `repack`: also write the gate's report as a JSON document (`build-report/1`) to this file — every gate's `PASS`, `SKIP` and `FAIL` rows, its summary figures and stats line, which supplier judged it, and every `pack:` line's figures. Written when the command writes `--out` and when a gate is red, before the exit; a file already at the path is removed first, so a compile error or a refused repack leaves none. Never inside `--out`, refused by name. The printed lines are byte-identical with it and without it — **§5.3** |
244
244
  | `--candidate` | `check`, `bench`, `render`, `inspect`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
245
245
  | `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
@@ -2252,6 +2252,22 @@ rigc therefore refuses a weighted `vertices` run unless the attachment says
2252
2252
  `"boneIndexing": "raw"` out loud — an opt-in, because what is being opted into is
2253
2253
  the silence.
2254
2254
 
2255
+ 🔢 **The order a vertex lists its bones in is free, until two vertices disagree.**
2256
+ The Spine editor refuses to import a weighted attachment whose vertices admit no
2257
+ single bone order — one vertex listing `link_a, link_b` and another
2258
+ `link_b, link_a`, three listing `a, b` · `b, c` · `c, a`, or one listing a bone
2259
+ twice — on a mesh, a bounding box, a clipping polygon and a path alike
2260
+ (`Error reading attachment: <name>` · `Index -2 out of bounds`), while spine-core
2261
+ reads every one of them. Weights written heaviest-first are exactly that shape. So
2262
+ `build` writes such an attachment with **every vertex's influences in ascending
2263
+ bone order** — in `skeleton.json` and in the model document, its `deform` runs
2264
+ moved with their influences (§4.11) — and leaves every other attachment in the
2265
+ order you gave it: one order per attachment is what the editor needs, and a
2266
+ descending one imports. `A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER` (§5.2) holds
2267
+ the file to it. Nothing about the pose changes but the order a vertex's
2268
+ influences are summed in, which is measured at ≤ 2 ulp of a posed vertex and no
2269
+ pixel (issue #1407).
2270
+
2255
2271
  ⚠️ `vertices` carries **no encoding flag** of its own, which is why the length rule
2256
2272
  above is load-bearing: if `vertices.length` equals `uvs.length` the parser reads
2257
2273
  unweighted x/y pairs, otherwise it reads the weighted run. A coincidental length
@@ -4937,6 +4953,41 @@ agreeing with `render` — and left out, a track steps the physics by the step i
4937
4953
  exactly as before. Which clock a player with a real clock uses is the player's
4938
4954
  question, and rigc does not answer it.
4939
4955
 
4956
+ 🌬️ **`wind` and `gravity` are the constraint's response; the world is the
4957
+ consumer's** (issue #1416). In 4.3 the two are not forces but how strongly the
4958
+ constraint answers two directions the *skeleton* carries — `Skeleton.windX,
4959
+ windY` and `gravityX, gravityY`, which start at `1, 0` and `0, 1` — so a
4960
+ constraint stating `wind: 20` is pushed along whatever direction the player
4961
+ sets, times 20 (and on `x` and `y` times `referenceScale`; on a rotation, at
4962
+ a lever of the bone's `length` over `referenceScale`). A live track of the
4963
+ core entry sets that direction with `setEnvironment({ wind: [x, y], gravity:
4964
+ [x, y] })` — x to the right, **y down**, either half left out to keep the one
4965
+ that stands — and the Spine export carries only the response, so an official
4966
+ runtime whose `Skeleton` is given the same four numbers poses what the core
4967
+ poses, to the bit (`CO56`). Two consequences an author has to know:
4968
+
4969
+ - **A constraint stating `wind: 0` feels no wind**, whatever the player sets.
4970
+ State a `wind` on every link that should answer a gust; leave it 0 on one
4971
+ that should not.
4972
+ - **Until the player sets one, `wind` pushes to the right.** The initial
4973
+ direction is the runtime's `[1, 0]`, not `[0, 0]`, so a rig stating `wind`
4974
+ leans at rest on every player that never calls `setEnvironment` — the
4975
+ official runtimes included. A player that wants calm at rest sets `wind:
4976
+ [0, 0]` and blows by setting it back. rigc does not change the initial
4977
+ value: that would make the export pose differently in the runtime than in
4978
+ the core.
4979
+
4980
+ Moving the rig is the consumer's too: `physicsTranslate(x, y)` and
4981
+ `physicsRotate(x, y, degrees)` on a live track are the runtime's methods of
4982
+ those names — the whole rig moved or turned since the last pose, in the
4983
+ skeleton's world (y up, degrees counter-clockwise) — and every constraint's
4984
+ next update sees its bone move by that much, so a rig walked across the screen
4985
+ trails its coat. A player forwards what spine-core's `SkeletonPhysicsMovement`
4986
+ forwards: its display object's change of position since the last frame in the
4987
+ skeleton's units and axes, and its change of rotation as `physicsRotate(0, 0,
4988
+ Δ)`, before the `step` that poses the frame. Nothing set, every pose is the
4989
+ one the track posed before these existed (`CO57`).
4990
+
4940
4991
  `mix` is a player-side `AnimationStateData` config and is **not** emitted into
4941
4992
  skeleton JSON, so the parser is the only check it gets: it is
4942
4993
  `{ "default": <seconds>, "pairs"?: [["<from>", "<to>", <seconds>], …] }`, and a
@@ -5291,7 +5342,11 @@ back on load; on a weighted one zero *is* "unmoved".
5291
5342
  on a weighted one.** Its length is `vertices.length` in the first case and
5292
5343
  `vertices.length / 3 * 2` in the second, because a weighted attachment stores
5293
5344
  `x, y, weight` per influence. A vertex with three bones on it therefore occupies
5294
- **three** pairs, each in that bone's own bind space. This is the whole reason
5345
+ **three** pairs, each in that bone's own bind space, in the order the spec lists
5346
+ the vertex's bones. Write a run against that order: where `build` rewrites an
5347
+ attachment's bone order (§3.4 — vertices that admit no single order), it moves
5348
+ each run's pairs with their influences, writing a 0 where the moved run has a gap.
5349
+ This is the whole reason
5295
5350
  `fromVertex` exists and the whole reason it is sometimes refused:
5296
5351
 
5297
5352
  - on an **unweighted** attachment `fromVertex` always works — vertex *v* is array
@@ -6538,9 +6593,9 @@ prints six rows and is one failure here.
6538
6593
  `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES` — the first two rows of the
6539
6594
  table below — which stand where `A00_ROUNDTRIP_PARSE`'s parse stands and have no
6540
6595
  round-trip counterpart, so they are not in the registry. That is the whole of the
6541
- difference between the two totals: `<N>` is 51 on the core entry and 49 on `cli.ts build`,
6596
+ difference between the two totals: `<N>` is 52 on the core entry and 50 on `cli.ts build`,
6542
6597
  and the core report's last line states the split — 42 rules on the model side,
6543
- which are the 40 registry assertions the model side runs and those two; the 8 restated over the emitted text;
6598
+ which are the 40 registry assertions the model side runs and those two; the 9 restated over the emitted text;
6544
6599
  and `A00_ROUNDTRIP_PARSE`, which it reports as a SKIP.
6545
6600
 
6546
6601
  🚨 **When `A00_ROUNDTRIP_PARSE` fails, read the report as a report about A00 and
@@ -6604,6 +6659,7 @@ Fix A00 and run it again.
6604
6659
  | <a id="A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP"></a>`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 |
6605
6660
  | <a id="A50_STAGE_BOX_IS_THE_STAGE"></a>`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 |
6606
6661
  | <a id="A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS"></a>`A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS` | both | a bone the rig holds to a line (`invariants.axes`, §3.7) whose world x axis at the setup pose — constraints applied, no physics, no skin set, the pose `A50` reads — is more than the entry's `tolerance` off the line through its two `through` bones' world origins, compared as lines (0° to 90°): `axis "face" (through "eye_r"→"eye_l", 30.0000°): bone "brow_l" world x axis at 36.0000°, 6.0000° off, tolerance 5° — rotate the bone in the rig spec onto the line and counter-rotate its attachments so the setup pose stays put`. Every angle is Spine world degrees (y up), counter-clockwise from +x. **Fix it in the rig spec**: set the bone's `rotation` so its world x axis lands on the axis angle the line prints, counter-rotate its attachments by the same angle so the setup pose does not move, and restate its children in its new frame. Also fails, by the entry's name: two `through` bones on one world origin (*share the world origin (x, y) at the setup pose, so they draw no line* — move one, or name another pair); a bone with no world x axis (a scale of 0); a bone inactive with no skin set (*so the setup pose places it nowhere*); a bone the skeleton does not have. Held whether or not an animation keys the bone: a consumer writes it at run time. Through spine-core it reads `appliedPose` on a fresh skeleton after `setupPose()` and `updateWorldTransform(Physics.none)`; on the model side rigc's core poser, held to spine-core's poses at tolerance 0 (`AX01`–`AX05`). **SKIP** when the rig declares no axes — *the rig "X" declares no axes (invariants.axes)* — and on a bare directory with no rig |
6662
+ | <a id="A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER"></a>`A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER` | both | a weighted mesh, bounding box, clipping polygon or path whose vertices list their bones in no single order — `mesh "default/hair/hair": no one bone order fits its vertices — vertex 0 lists bone 11 before 18 (its bones: 11, 18), vertex 9 lists bone 18 before 11 (its bones: 18, 11)`, or three vertices in a cycle (`a` before `b`, `b` before `c`, `c` before `a`), or one vertex that lists a bone twice. The numbers are bone indices in the skeleton's `bones` array, and the vertex numbers count from 0 in the run. spine-core loads all of these; the Spine editor refuses the import (`Error reading attachment: <name>` · `Index -2 out of bounds`, measured on 4.3.26 on all four types), so this is the one place the round trip cannot see the fault. A descending order, or any order shared by every vertex, is accepted: four of the twelve editor exports in the example corpus are out of ascending order and pass. **A `build` never fails it** — the compiler writes an attachment with no single order in ascending bone order (§3.4); it fires on a Spine file written or edited by something else. **Fix it in the file**: list every vertex's `(index, x, y, weight)` tuples in one order — ascending index is one — and move each deform key's pairs with them; or rebuild through `rigc build`. **SKIP** when the skeleton has no weighted vertex attachment. Under `cli_core.ts build` the same clause runs over the text the emitter wrote and prints this line in the same words (`RC28`). |
6607
6663
 
6608
6664
  `both ◑` marks a mixed assertion: its validity half always runs and its policy
6609
6665
  clauses are gated by profile.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.51.0",
3
+ "version": "2.52.0",
4
4
  "description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,146 @@
1
+ /**
2
+ * A52 (issue #1407): every weighted vertex attachment lists its bones in one
3
+ * order — the order the Spine editor needs and spine-core does not. Run over
4
+ * the skeleton JSON by both entries: `validate()` in `src/validate.ts` over
5
+ * the text it hands spine-core, and the entry that links none of the runtime
6
+ * over the text rigc wrote (`../emitted/index.ts`), so the selftest's `RC28`
7
+ * holds the two to the same lines on every recipe and on this assertion's
8
+ * mutant.
9
+ *
10
+ * ⭐ **The rule is a cycle, measured, and not "ascending".** With Spine 4.3.26
11
+ * Professional (`tools/editor_roundtrip.ts`), the import refuses an attachment
12
+ * when one vertex lists bones `a, b` and another `b, a`, when three vertices
13
+ * list `a, b` · `b, c` · `c, a` with no pair reversed, and when one vertex
14
+ * lists a bone twice — `Error reading attachment: <name>` · `Index -2 out of
15
+ * bounds` — on a mesh, a bounding box, a clipping polygon and a path alike.
16
+ * The same rig with every vertex in DESCENDING index order imports, exports,
17
+ * validates and checks; and four of the twelve editor exports in the example
18
+ * corpus list bones out of ascending order, each in one order per attachment.
19
+ * So "strictly ascending" would refuse Esoteric's own files, and this refuses
20
+ * exactly the shape the editor refuses. spine-core reads every one of these
21
+ * without a word, which is why it is an assertion: the round trip passes it.
22
+ *
23
+ * Re-derived from the emitted indices on purpose, with nothing shared with
24
+ * `compile.ts`, which makes the build's order hold this — the gate does not
25
+ * check the compiler with the compiler's code.
26
+ *
27
+ * Links nothing from the runtime.
28
+ */
29
+ import type { Verdicts } from '../harness.ts';
30
+ import { isObj, type Json } from '../values.ts';
31
+
32
+ const CODE = 'A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER';
33
+
34
+ /** The four attachment types a weighted vertex run can belong to. */
35
+ const VERTEX_TYPES = new Set(['mesh', 'boundingbox', 'clipping', 'path']);
36
+
37
+ /** One vertex's bone indices, in the order the run lists them. */
38
+ type Listed = { vertex: number; bones: number[] };
39
+
40
+ /**
41
+ * A weighted run's vertices, each with the bone indices it lists — or `null`
42
+ * when the attachment is unweighted or its run does not decode (A04, A20 and
43
+ * A33 own that shape; this measures order, not encoding).
44
+ */
45
+ function weightedVertices(att: Json): Listed[] | null {
46
+ const type = typeof att.type === 'string' ? att.type : 'region';
47
+ if (!VERTEX_TYPES.has(type) || !Array.isArray(att.vertices)) return null;
48
+ const length = type === 'mesh' ? (Array.isArray(att.uvs) ? att.uvs.length : null) : typeof att.vertexCount === 'number' ? att.vertexCount * 2 : null;
49
+ const run = att.vertices as unknown[];
50
+ if (length === null || run.length === length) return null;
51
+ const out: Listed[] = [];
52
+ for (let i = 0; i < run.length; ) {
53
+ const n = run[i++];
54
+ if (typeof n !== 'number' || !Number.isInteger(n) || n < 1 || i + n * 4 > run.length) return null;
55
+ const bones: number[] = [];
56
+ for (let k = 0; k < n; k++) {
57
+ const bone = run[i + k * 4];
58
+ if (typeof bone !== 'number') return null;
59
+ bones.push(bone);
60
+ }
61
+ out.push({ vertex: out.length, bones });
62
+ i += n * 4;
63
+ }
64
+ return out;
65
+ }
66
+
67
+ /** `vertex 3 lists bone 18 before 11 (its bones: 18, 11)`. */
68
+ function step(listed: Listed, from: number, to: number): string {
69
+ return `vertex ${listed.vertex} lists bone ${from} before ${to} (its bones: ${listed.bones.join(', ')})`;
70
+ }
71
+
72
+ /**
73
+ * Why no one order fits these vertices, in the vertices' own words — or
74
+ * `null` when one does. A vertex naming a bone twice is its own reason; a
75
+ * cycle is found by a depth-first walk over "listed directly before", in
76
+ * the order the run first lists each bone, and every edge of it is named by
77
+ * the first vertex that lists it.
78
+ */
79
+ function noOneOrder(vertices: Listed[]): string | null {
80
+ for (const listed of vertices) {
81
+ const twice = listed.bones.find((bone, k) => listed.bones.indexOf(bone) !== k);
82
+ if (twice !== undefined) return `vertex ${listed.vertex} lists bone ${twice} twice (its bones: ${listed.bones.join(', ')})`;
83
+ }
84
+ /** bone -> the bones listed directly after it, each with the first vertex that does. */
85
+ const after = new Map<number, Map<number, Listed>>();
86
+ for (const listed of vertices) {
87
+ for (let k = 1; k < listed.bones.length; k++) {
88
+ const from = listed.bones[k - 1];
89
+ if (!after.has(from)) after.set(from, new Map());
90
+ const next = after.get(from)!;
91
+ if (!next.has(listed.bones[k])) next.set(listed.bones[k], listed);
92
+ }
93
+ }
94
+ const state = new Map<number, 'open' | 'done'>();
95
+ const path: number[] = [];
96
+ const walk = (bone: number): number[] | null => {
97
+ state.set(bone, 'open');
98
+ path.push(bone);
99
+ for (const next of after.get(bone)?.keys() ?? []) {
100
+ if (state.get(next) === 'open') return [...path.slice(path.indexOf(next)), next];
101
+ if (!state.has(next)) {
102
+ const cycle = walk(next);
103
+ if (cycle !== null) return cycle;
104
+ }
105
+ }
106
+ path.pop();
107
+ state.set(bone, 'done');
108
+ return null;
109
+ };
110
+ for (const bone of after.keys()) {
111
+ if (state.has(bone)) continue;
112
+ const cycle = walk(bone);
113
+ if (cycle === null) continue;
114
+ const steps: string[] = [];
115
+ for (let k = 1; k < cycle.length; k++) steps.push(step(after.get(cycle[k - 1])!.get(cycle[k])!, cycle[k - 1], cycle[k]));
116
+ return steps.join(', ');
117
+ }
118
+ return null;
119
+ }
120
+
121
+ export function a52WeightedVerticesShareOneBoneOrder({ fail, skip }: Verdicts, raw: Json | null): void {
122
+ if (!raw) return skip(CODE, 'the skeleton JSON did not parse (A00 owns that failure)');
123
+ let measured = 0;
124
+ for (const skin of Array.isArray(raw.skins) ? (raw.skins as unknown[]) : []) {
125
+ if (!isObj(skin) || !isObj(skin.attachments)) continue;
126
+ const skinName = typeof skin.name === 'string' ? skin.name : '(unnamed)';
127
+ for (const [slot, perSlot] of Object.entries(skin.attachments)) {
128
+ if (!isObj(perSlot)) continue;
129
+ for (const [name, att] of Object.entries(perSlot)) {
130
+ if (!isObj(att)) continue;
131
+ const vertices = weightedVertices(att);
132
+ if (vertices === null) continue;
133
+ measured++;
134
+ const why = noOneOrder(vertices);
135
+ if (why === null) continue;
136
+ fail(
137
+ CODE,
138
+ `${String(att.type)} "${skinName}/${slot}/${name}": no one bone order fits its vertices — ${why}. ` +
139
+ 'spine-core reads it; the Spine editor refuses the import ("Index -2 out of bounds"). ' +
140
+ 'List every vertex\'s bones in one order — ascending bone index is one, and the order `rigc build` writes',
141
+ );
142
+ }
143
+ }
144
+ }
145
+ if (measured === 0) return skip(CODE, 'no weighted vertex attachment to measure');
146
+ }
@@ -3,14 +3,15 @@
3
3
  * step 4e of #380) — the half of the gate the entry that links none of
4
4
  * spine-core runs beside the model side (`../model/index.ts`).
5
5
  *
6
- * ⭐ **Every one of the 50 is accounted for, by name.** The model side runs
6
+ * ⭐ **Every assertion in the registry is accounted for, by name.** The model side runs
7
7
  * the moved assertions (`MOVED_ASSERTIONS`) over the document. What is left —
8
8
  * the complement, read off `ASSERTION_KIND` rather than listed — is the
9
- * round trip's own: A00, A01, A02, A05, A07, A16, A18, A31, A35 at the time of
9
+ * round trip's own: A00, A01, A02, A05, A07, A16, A18, A31, A35, A52 at the time of
10
10
  * writing. Each of those but one reads the raw text, not what spine-core
11
11
  * loaded: the atlas's line layout (A07), the skeleton JSON's draw-order
12
- * offsets, deform runs, version label, legacy arrays, legacy bone key and
13
- * curve arrays (A31, A35, A16, A01, A02, A05), and A18's second compile. So
12
+ * offsets, deform runs, weighted runs' bone order, version label, legacy arrays,
13
+ * legacy bone key and curve arrays (A31, A35, A52, A16, A01, A02, A05), and A18's
14
+ * second compile. So
14
15
  * each is restated here over the same text, by a body under `../bodies/` that
15
16
  * is the round trip's clause word for word, and prints the round trip's line
16
17
  * (`RC28` holds the two on every recipe and on each assertion's mutants).
@@ -35,6 +36,7 @@ import { a16SkeletonVersion43 } from '../bodies/a16.ts';
35
36
  import { a18DeterministicEmit } from '../bodies/a18.ts';
36
37
  import { a31DrawOrderOffsetsResolve } from '../bodies/a31.ts';
37
38
  import { a35DeformKeysFitTheAttachment } from '../bodies/a35.ts';
39
+ import { a52WeightedVerticesShareOneBoneOrder } from '../bodies/a52.ts';
38
40
  import { parseAtlasText } from '../../atlas.ts';
39
41
 
40
42
  /** What the restated rules read: the emitted texts, the document written beside them, and a second compile's three. */
@@ -72,6 +74,7 @@ export const EMITTED_TEXT_RULES: ReadonlyArray<{ code: string; run: (v: Verdicts
72
74
  { code: 'A07_ATLAS_TEXT_SHAPE', run: (v, t) => a07AtlasTextShape(v, t.input.atlasText) },
73
75
  { code: 'A31_DRAW_ORDER_OFFSETS_RESOLVE', run: (v, t) => a31DrawOrderOffsetsResolve(v, t.raw) },
74
76
  { code: 'A35_DEFORM_KEYS_FIT_THE_ATTACHMENT', run: (v, t) => a35DeformKeysFitTheAttachment(v, t.raw) },
77
+ { code: 'A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER', run: (v, t) => a52WeightedVerticesShareOneBoneOrder(v, t.raw) },
75
78
  { code: A00, run: (v) => v.skip(A00, SKIP_NO_ROUND_TRIP) },
76
79
  { code: 'A16_SKELETON_VERSION_4_3', run: (v, t) => a16SkeletonVersion43(v, t.raw) },
77
80
  { code: 'A01_NO_LEGACY_TOPLEVEL_CONSTRAINT_ARRAYS', run: (v, t) => a01NoLegacyToplevelConstraintArrays(v, t.raw) },
@@ -128,6 +128,7 @@ export const ASSERTION_KIND: Readonly<Record<string, AssertionKind>> = {
128
128
  A49_PACKED_FOOTPRINTS_DO_NOT_OVERLAP: 'renderer',
129
129
  A50_STAGE_BOX_IS_THE_STAGE: 'validity',
130
130
  A51_BONES_LIE_ALONG_THEIR_DECLARED_AXIS: 'validity',
131
+ A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER: 'validity',
131
132
  };
132
133
 
133
134
  /** Whether `profile` runs a rule of `kind`: `spine` runs validity alone, `rig-play` every kind. */
package/src/cli/shared.ts CHANGED
@@ -1337,7 +1337,7 @@ export const COMMANDS: CommandDoc[] = [
1337
1337
  'trip through spine-core, which this entry links none of. What runs instead: the model',
1338
1338
  'side over the document (every assertion moved off the round trip), and the round',
1339
1339
  'trip\'s own rules restated over the text the emitter wrote — A01, A02, A05, A07, A16,',
1340
- 'A31, A35, and A18 over a second, independent compile\'s skeleton, atlas and document.',
1340
+ 'A31, A35, A52, and A18 over a second, independent compile\'s skeleton, atlas and document.',
1341
1341
  'What does not run is A00_ROUNDTRIP_PARSE, spine-core\'s parse: it reports SKIP naming',
1342
1342
  'spine-core. The report\'s last line says which ran here and which did not. A00 runs',
1343
1343
  'in build and validate on the entry that links spine-core: installed, the same `rigc`',
package/src/compile.ts CHANGED
@@ -162,6 +162,7 @@ import {
162
162
  type ModelSlot,
163
163
  type ModelStage,
164
164
  type ModelTimelines,
165
+ type ModelVertexAttachment,
165
166
  type ModelVertices,
166
167
  type SkinTable,
167
168
  type SkinTableEntry,
@@ -3695,6 +3696,10 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
3695
3696
  if (!boneNames.has(slot.bone)) throw new CompileError(`slot "${slot.name}" has no bone`);
3696
3697
  }
3697
3698
 
3699
+ // One bone order per weighted attachment (issue #1407), on the finished
3700
+ // model, so the document, both posers and the Spine file read one order.
3701
+ oneBoneOrderPerAttachment(bones, skins, animations, deformTransforms);
3702
+
3698
3703
  // The fields the model carries as `CompileResult` carries them, by reference:
3699
3704
  // one object, so the result and its model cannot say two different things.
3700
3705
  const carried: CarriedFromCompileResult = {
@@ -4451,6 +4456,142 @@ function bindRawRun(run: readonly number[], bones: readonly ModelBone[], where:
4451
4456
  return out;
4452
4457
  }
4453
4458
 
4459
+ /**
4460
+ * Whether one bone order fits every vertex of a weighted attachment: no vertex
4461
+ * lists a bone twice, and no two vertices — directly or through a chain of
4462
+ * others — list two bones in opposite orders. Read off the bindings by name;
4463
+ * `A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER` re-derives the same question
4464
+ * from the emitted indices, on purpose with its own code.
4465
+ */
4466
+ function bindingsShareOneBoneOrder(bindings: readonly ModelBinding[][]): boolean {
4467
+ const after = new Map<string, Set<string>>();
4468
+ for (const vertex of bindings) {
4469
+ if (new Set(vertex.map((b) => b.bone)).size !== vertex.length) return false;
4470
+ for (let i = 1; i < vertex.length; i++) {
4471
+ const from = vertex[i - 1].bone;
4472
+ if (!after.has(from)) after.set(from, new Set());
4473
+ after.get(from)!.add(vertex[i].bone);
4474
+ }
4475
+ }
4476
+ const state = new Map<string, 'open' | 'done'>();
4477
+ const acyclicFrom = (bone: string): boolean => {
4478
+ state.set(bone, 'open');
4479
+ for (const next of after.get(bone) ?? []) {
4480
+ const seen = state.get(next);
4481
+ if (seen === 'open') return false;
4482
+ if (seen === undefined && !acyclicFrom(next)) return false;
4483
+ }
4484
+ state.set(bone, 'done');
4485
+ return true;
4486
+ };
4487
+ for (const bone of after.keys()) if (!state.has(bone) && !acyclicFrom(bone)) return false;
4488
+ return true;
4489
+ }
4490
+
4491
+ /**
4492
+ * Write every weighted attachment whose vertices admit no single bone order
4493
+ * with each vertex's influences in ascending bone order (issue #1407) — the
4494
+ * one rewrite of a spec's binding order the compiler makes, and the reason it
4495
+ * makes it.
4496
+ *
4497
+ * ⭐ **What the Spine editor refuses is a cycle, not a descending pair.**
4498
+ * Measured with Spine 4.3.26 Professional through `tools/editor_roundtrip.ts`:
4499
+ * an attachment where one vertex lists bones `a, b` and another `b, a` — or
4500
+ * three vertices `a, b` · `b, c` · `c, a`, or one vertex `a, a` — is refused
4501
+ * on import (`Error reading attachment: <name>` · `Index -2 out of bounds`),
4502
+ * on a mesh, a bounding box, a clipping polygon and a path alike; the same
4503
+ * rig with every vertex in DESCENDING order imports, exports and checks, and
4504
+ * four of the twelve editor exports in this tree's example corpus list bones
4505
+ * out of ascending order, every one of them in a single order per attachment.
4506
+ * So an attachment that already has one order keeps the spec's, and its bytes
4507
+ * do not move; one that has none takes ascending order, which every vertex
4508
+ * shares by construction.
4509
+ *
4510
+ * 🔒 **On the model, not in the emitter.** The document, rigc's poser and the
4511
+ * Spine file must read one order: a weighted vertex is `Σ wᵢ·Mᵢ·bindᵢ`, and
4512
+ * summed in two orders its last bits differ — sorting in the emitter alone
4513
+ * was measured to put the two posers 1–2 ulp apart on a parts-built rig.
4514
+ *
4515
+ * 🔸 A deform key on such an attachment is one `x, y` per INFLUENCE, in the
4516
+ * influences' order, so each key's run is moved with them: spread over the
4517
+ * deform array, permuted pair by pair, and cut back to the first and last
4518
+ * index it covers, an index it does not cover written as the 0 the runtime
4519
+ * reads there. The report of a generated run (`expanded`) is moved the same
4520
+ * way, so it still carries what was emitted.
4521
+ */
4522
+ function oneBoneOrderPerAttachment(
4523
+ bones: readonly ModelBone[],
4524
+ skins: readonly ModelSkin[],
4525
+ animations: ReadonlyMap<string, CompiledAnimation>,
4526
+ deformTransforms: CompileResult['deformTransforms'],
4527
+ ): void {
4528
+ const position = new Map(bones.map((bone, i) => [bone.name, i] as const));
4529
+ /** Per reordered attachment record: where each influence went, old influence index -> new. */
4530
+ const moved = new Map<ModelVertexAttachment, number[]>();
4531
+ for (const skin of skins) {
4532
+ for (const perSlot of Object.values(skin.attachments)) {
4533
+ for (const record of Object.values(perSlot)) {
4534
+ if (!isModelVertexAttachment(record) || !record.vertices.weighted || moved.has(record)) continue;
4535
+ if (bindingsShareOneBoneOrder(record.vertices.bindings)) continue;
4536
+ const to: number[] = [];
4537
+ let at = 0;
4538
+ const bindings = record.vertices.bindings.map((vertex) => {
4539
+ const order = vertex.map((_, i) => i).sort((p, q) => position.get(vertex[p].bone)! - position.get(vertex[q].bone)! || p - q);
4540
+ const rank = new Array<number>(vertex.length);
4541
+ order.forEach((from, k) => (rank[from] = k));
4542
+ for (let i = 0; i < vertex.length; i++) to.push(at + rank[i]);
4543
+ at += vertex.length;
4544
+ return order.map((i) => vertex[i]);
4545
+ });
4546
+ record.vertices = { weighted: true, bindings };
4547
+ moved.set(record, to);
4548
+ }
4549
+ }
4550
+ }
4551
+ if (moved.size === 0) return;
4552
+ /** A run moved with its influences: `start` and `run` over a deform array of `to.length` pairs. */
4553
+ const movedRun = (start: number, run: readonly number[], to: readonly number[]): { start: number; run: number[] } => {
4554
+ const spread = new Map<number, number>();
4555
+ run.forEach((value, i) => {
4556
+ const index = start + i;
4557
+ spread.set(to[index >> 1] * 2 + (index & 1), value);
4558
+ });
4559
+ const indices = [...spread.keys()];
4560
+ const first = Math.min(...indices);
4561
+ const last = Math.max(...indices);
4562
+ const out: number[] = [];
4563
+ for (let i = first; i <= last; i++) out.push(spread.get(i) ?? 0);
4564
+ return { start: first, run: out };
4565
+ };
4566
+ const recordOf = (skin: string, slot: string, placeholder: string): ModelVertexAttachment | undefined => {
4567
+ const record = skins.find((s) => s.name === skin)?.attachments[slot]?.[placeholder];
4568
+ return record !== undefined && isModelVertexAttachment(record) && moved.has(record) ? record : undefined;
4569
+ };
4570
+ for (const animation of animations.values()) {
4571
+ for (const [skin, bySlot] of animation.attachments) {
4572
+ for (const [slot, byPlaceholder] of bySlot) {
4573
+ for (const [placeholder, timelines] of byPlaceholder) {
4574
+ const record = recordOf(skin, slot, placeholder);
4575
+ if (record === undefined || timelines.deform === undefined) continue;
4576
+ const to = moved.get(record)!;
4577
+ timelines.deform = timelines.deform.map((key) => {
4578
+ if (!Array.isArray(key.vertices) || key.vertices.length === 0) return key;
4579
+ const { start, run } = movedRun(typeof key.offset === 'number' ? key.offset : 0, key.vertices as number[], to);
4580
+ // The key's fields in the order `compileDeformTrack` inserts them: `time`, `offset`, `vertices`, then the curve.
4581
+ const rest = Object.fromEntries(Object.entries(key).filter(([field]) => field !== 'time' && field !== 'offset' && field !== 'vertices'));
4582
+ return { time: key.time, ...(start !== 0 ? { offset: start } : {}), vertices: run, ...rest };
4583
+ });
4584
+ }
4585
+ }
4586
+ }
4587
+ }
4588
+ for (const report of deformTransforms) {
4589
+ const record = recordOf(report.skin, report.slot, report.attachment);
4590
+ if (record === undefined || report.expanded === undefined) continue;
4591
+ report.expanded = movedRun(0, report.expanded, moved.get(record)!).run;
4592
+ }
4593
+ }
4594
+
4454
4595
  /** Every number of a weighted binding list on the float32 grid — what `.map(f32)` did to the run. */
4455
4596
  function bindingsOnF32(vertices: Extract<ModelVertices, { weighted: true }>): Extract<ModelVertices, { weighted: true }> {
4456
4597
  return {
@@ -189,6 +189,34 @@
189
189
  * reading is rejected as a schedule no player runs; the core suite's `SC`
190
190
  * controls plant it and turn the probe red on the steps after the key.
191
191
  *
192
+ * ## The environment (issue #1416)
193
+ *
194
+ * A constraint's `wind` and `gravity` are not forces: they are how strongly it
195
+ * answers two directions the SKELETON carries — spine-core 4.3.13's
196
+ * `Skeleton.windX, windY` and `gravityX, gravityY`, initially `1, 0` and `0,
197
+ * 1` (`DEFAULT_PHYSICS_ENVIRONMENT`). The step weighs them as the runtime
198
+ * does: for `x` and `y`, `ax = (wind·windX + gravity·gravityX)·R` and `ay =
199
+ * (wind·windY + gravity·gravityY)·R`, `velocity += (ax − offset·strength)·…`
200
+ * on `x` and `−= (ay + offset·strength)·…` on `y`; for the rotation and the
201
+ * scale the same two sums without `R`, `(ax·sin + ay·cos)·length/R` and `ax·cos
202
+ * − ay·sin` in place of the wind and gravity terms above. The skeleton's own
203
+ * scale, which the runtime multiplies the `x` and `y` sums by, is 1 here. So
204
+ * the environment's y points DOWN — `[0, 1]` pulls toward the bottom of the
205
+ * screen — and with the initial values every expression is the one this file
206
+ * computed before the environment existed: `wind·1 + gravity·0` is `wind` for
207
+ * every finite `gravity` (a `wind` of −0 aside, which it reads as +0, as the
208
+ * runtime does), and the render and emit bases did not move.
209
+ *
210
+ * The consumer sets it (`setEnvironment` on a live track, `./track.ts`), and
211
+ * moves the rig from outside with `translatePhysics` and `rotatePhysics` —
212
+ * `Skeleton.physicsTranslate` and `physicsRotate`, which shift what each
213
+ * constraint last saw (`ux, uy, cx, cy`) so its next update reads the bone as
214
+ * having moved. A constraint with no state yet is pending, and its first
215
+ * update overwrites both, so it is left alone, as the runtime's translation of
216
+ * it is read by nothing. The core suite's `CO56` holds all of it to spine-core
217
+ * given the same values at tolerance 0, and `CO57` that nothing set poses what
218
+ * was posed before.
219
+ *
192
220
  * ## What is not exact, and why
193
221
  *
194
222
  * Over 3,000 random rigs of three shapes (all five inherit modes; ik and
@@ -215,6 +243,9 @@
215
243
  */
216
244
  import { channelAt, keyIndexAt, poseAnimations, sampleTime, type CoreAnimationPose, type CoreCurve, type CoreKey, type SamplePhase, type TimelinePlant } from './animation.ts';
217
245
  import { RUNTIME_PI, type CoreWorld } from './world.ts';
246
+
247
+ /** Degrees to radians as the runtime converts them (`MathUtils.degRad`, `pi / 180` with the runtime's pi). */
248
+ const RUNTIME_RAD = RUNTIME_PI / 180;
218
249
  import { previousPassSlotBones, type CoreConstraintRecord } from './constraints.ts';
219
250
  import { activeBones, type CompiledDocument } from './index.ts';
220
251
 
@@ -577,6 +608,71 @@ export interface PhysicsStepContext {
577
608
  keyedNow?: Set<string>;
578
609
  /** A plant only (issue #1049, `sliderPhysicsFromCurrent: false`): each physics record's setup values, before the step's own animation. */
579
610
  setupRecords?: Map<string, CorePhysicsRecord>;
611
+ /**
612
+ * The world every constraint answers (issue #1416, the header's *The
613
+ * environment*): the skeleton's wind and gravity directions, as a caller set
614
+ * them on the live track. Absent, spine-core's own initial values
615
+ * (`DEFAULT_PHYSICS_ENVIRONMENT`), which is every walk but a live one a
616
+ * caller set it on.
617
+ */
618
+ env?: PhysicsEnvironment;
619
+ }
620
+
621
+ /**
622
+ * The world a physics constraint answers (issue #1416): spine-core 4.3.13's
623
+ * four skeleton fields `windX`, `windY`, `gravityX` and `gravityY`, as
624
+ * `wind: [windX, windY]` and `gravity: [gravityX, gravityY]` — the
625
+ * DIRECTIONS the constraints' own `wind` and `gravity` act along, which are
626
+ * each constraint's response to them (the header's *The environment*). In the
627
+ * runtime's convention: x to the right, **y down** — a positive y component
628
+ * pulls a constraint toward the bottom of the screen, as `gravity: [0, 1]`
629
+ * does.
630
+ */
631
+ export interface PhysicsEnvironment {
632
+ readonly wind: readonly [number, number];
633
+ readonly gravity: readonly [number, number];
634
+ }
635
+
636
+ /** spine-core 4.3.13's initial `Skeleton.windX, windY, gravityX, gravityY` — `1, 0, 0, 1` — the world of every walk nobody set one on. */
637
+ export const DEFAULT_PHYSICS_ENVIRONMENT: PhysicsEnvironment = Object.freeze({ wind: Object.freeze([1, 0] as const), gravity: Object.freeze([0, 1] as const) });
638
+
639
+ /**
640
+ * `Skeleton.physicsTranslate(x, y)` on the states in `ctx` (issue #1416): every
641
+ * constraint's next update sees its bone moved by `(x, y)` in the skeleton's
642
+ * world (y up) — the consumer moved the whole rig by that much since the last
643
+ * pose. A constraint with no state yet has not been updated, and its first
644
+ * update is pending, so the runtime's translation of it is read by nothing.
645
+ */
646
+ export function translatePhysics(ctx: PhysicsStepContext, x: number, y: number): void {
647
+ for (const s of ctx.states.values()) {
648
+ s.ux -= x;
649
+ s.uy -= y;
650
+ s.cx -= x;
651
+ s.cy -= y;
652
+ }
653
+ }
654
+
655
+ /**
656
+ * `Skeleton.physicsRotate(x, y, degrees)` on the states in `ctx` (issue
657
+ * #1416): every constraint's next update sees its bone turned by `degrees`
658
+ * (counter-clockwise, the runtime's pi) about `(x, y)` in the skeleton's world —
659
+ * a translation of each constraint by how far that turn carries the origin it
660
+ * last saw, as `PhysicsConstraint.rotate` computes it.
661
+ */
662
+ export function rotatePhysics(ctx: PhysicsStepContext, x: number, y: number, degrees: number): void {
663
+ const r = degrees * RUNTIME_RAD;
664
+ const cos = Math.cos(r);
665
+ const sin = Math.sin(r);
666
+ for (const s of ctx.states.values()) {
667
+ const dx = s.cx - x;
668
+ const dy = s.cy - y;
669
+ const tx = dx * cos - dy * sin - dx;
670
+ const ty = dx * sin + dy * cos - dy;
671
+ s.ux -= tx;
672
+ s.uy -= ty;
673
+ s.cx -= tx;
674
+ s.cy -= ty;
675
+ }
580
676
  }
581
677
 
582
678
  /** A constraint's state in `ctx`, created fresh on its first update. */
@@ -634,6 +730,7 @@ export function stepPhysics(r: CorePhysicsRecord, w: CoreWorld, length: number,
634
730
  s.lastTime = ctx.time;
635
731
  const bx = w.worldX;
636
732
  const by = w.worldY;
733
+ const env = ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
637
734
  let z = 0;
638
735
  if (s.pending) {
639
736
  s.pending = false;
@@ -664,16 +761,19 @@ export function stepPhysics(r: CorePhysicsRecord, w: CoreWorld, length: number,
664
761
  const e = r.strength;
665
762
  const wind = r.wind * f;
666
763
  const g = r.gravity * f;
764
+ // The environment's directions (issue #1416), as the runtime weighs them; the skeleton's scale is 1 here, so its factor is left out.
765
+ const ax = wind * env.wind[0] + g * env.gravity[0];
766
+ const ay = wind * env.wind[1] + g * env.gravity[1];
667
767
  const xs = s.xOffset;
668
768
  const ys = s.yOffset;
669
769
  do {
670
770
  if (x) {
671
- s.xVelocity += (wind - s.xOffset * e) * m;
771
+ s.xVelocity += (ax - s.xOffset * e) * m;
672
772
  s.xOffset += s.xVelocity * t;
673
773
  s.xVelocity *= d;
674
774
  }
675
775
  if (y) {
676
- s.yVelocity -= (g + s.yOffset * e) * m;
776
+ s.yVelocity -= (ay + s.yOffset * e) * m;
677
777
  s.yOffset += s.yVelocity * t;
678
778
  s.yVelocity *= d;
679
779
  }
@@ -720,20 +820,20 @@ export function stepPhysics(r: CorePhysicsRecord, w: CoreWorld, length: number,
720
820
  if (d === -1) d = Math.pow(r.damping, 60 * t);
721
821
  const m = r.massInverse * t;
722
822
  const e = r.strength;
723
- const wind = r.wind;
724
- const g = r.gravity;
823
+ const ax = r.wind * env.wind[0] + r.gravity * env.gravity[0];
824
+ const ay = r.wind * env.wind[1] + r.gravity * env.gravity[1];
725
825
  const h = l / f;
726
826
  const rs = s.rotateOffset;
727
827
  const ss = s.scaleOffset;
728
828
  for (;;) {
729
829
  a -= t;
730
830
  if (scaleX) {
731
- s.scaleVelocity += (wind * c - g * sn - s.scaleOffset * e) * m;
831
+ s.scaleVelocity += (ax * c - ay * sn - s.scaleOffset * e) * m;
732
832
  s.scaleOffset += s.scaleVelocity * t;
733
833
  s.scaleVelocity *= d;
734
834
  }
735
835
  if (rotateOrShearX) {
736
- s.rotateVelocity -= ((wind * sn + g * c) * h + s.rotateOffset * e) * m;
836
+ s.rotateVelocity -= ((ax * sn + ay * c) * h + s.rotateOffset * e) * m;
737
837
  s.rotateOffset += s.rotateVelocity * t;
738
838
  s.rotateVelocity *= d;
739
839
  if (a < t) break;
package/src/core/entry.ts CHANGED
@@ -18,7 +18,9 @@
18
18
  * ⚠️ A symbol is listed here only because a dependant was seen using it,
19
19
  * or asked for it in writing before it could be (`openTrack`, issue #1276;
20
20
  * `openTracks`, issue #1336; `physicsClockStep` and `PHYSICS_CLOCK_LEAD`,
21
- * the clock a live track's `clock` option takes, issue #1396).
21
+ * the clock a live track's `clock` option takes, issue #1396;
22
+ * `PhysicsEnvironment`, what a live track's `setEnvironment` takes, issue
23
+ * #1416).
22
24
  * The list is `scripts/install_smoke.ts`'s `OBSERVED_SYMBOLS` rows for
23
25
  * `./core`, and a symbol added here and not there is not promised.
24
26
  */
@@ -28,7 +30,7 @@ export { poseRawSetup, poseRawAnimation, poseRawAnimationEach, loopedTime, setup
28
30
  export type { RawPose, RawBone, RawDrawn, RawClip, RawClipped, RawEvent, RawReset } from './raw.ts';
29
31
  export { poseWalkSetup, poseLoopingWalk } from './walk.ts';
30
32
  export { openTrack } from './track.ts';
31
- export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust } from './track.ts';
33
+ export type { LiveTrack, TrackOptions, AdjustableLocals, BoneLocals, LocalAdjust, PhysicsEnvironment } from './track.ts';
32
34
  export { openTracks } from './tracks.ts';
33
35
  export type { LiveTracks, TracksOptions, MixOptions, QueueOptions, TrackEntryView } from './tracks.ts';
34
36
  export type { WalkPose } from './walk.ts';
package/src/core/raw.ts CHANGED
@@ -116,7 +116,7 @@
116
116
  import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
117
117
  import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type CoreAnimationTimelines, type LocalAdjust, type TimelinePlant } from './animation.ts';
118
118
  import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
119
- import { freshStepContext, steppedPreviousPassWhy } from './constraints_physics.ts';
119
+ import { freshStepContext, rotatePhysics, steppedPreviousPassWhy, translatePhysics, DEFAULT_PHYSICS_ENVIRONMENT, type PhysicsEnvironment, type PhysicsStepContext } from './constraints_physics.ts';
120
120
  import { attachmentStates } from './deform.ts';
121
121
  import { drawOrderAt, type CoreDrawOrderKey } from './draw_order.ts';
122
122
  import { eventsFired, type CoreEventRow } from './events.ts';
@@ -575,6 +575,53 @@ export function checkedClock(clock: (dt: number) => number, who: string): (dt: n
575
575
  };
576
576
  }
577
577
 
578
+ /**
579
+ * A live track's `setEnvironment` (issue #1416, `./track.ts` and `./tracks.ts`):
580
+ * the world every physics constraint answers from the next pose on, set on the
581
+ * track's physics context — `wind` and `gravity` each a pair of finite numbers,
582
+ * either left out to keep the one that stands, and nothing else — or refused by
583
+ * name, before anything moves. The pair is copied: the caller's array changed
584
+ * later moves nothing.
585
+ */
586
+ export function setEnvironmentOn(ctx: PhysicsStepContext, env: unknown, who: string): void {
587
+ if (typeof env !== 'object' || env === null || Array.isArray(env)) throw new CoreInputError(`${who} setEnvironment was given ${env === null ? 'null' : Array.isArray(env) ? 'an array' : typeof env}, not an object { wind?: [x, y], gravity?: [x, y] }`);
588
+ const given = env as Record<string, unknown>;
589
+ for (const key of Object.keys(given)) {
590
+ if (key !== 'wind' && key !== 'gravity') throw new CoreInputError(`${who} setEnvironment names "${key}", which is not one of [wind, gravity]`);
591
+ }
592
+ const pair = (key: 'wind' | 'gravity'): readonly [number, number] | undefined => {
593
+ const v = given[key];
594
+ if (v === undefined) return undefined;
595
+ if (!Array.isArray(v) || v.length !== 2 || v.some((n) => typeof n !== 'number' || !Number.isFinite(n))) throw new CoreInputError(`${who} setEnvironment's ${key} is ${JSON.stringify(v)}, not a pair of finite numbers [x, y] (x to the right, y down)`);
596
+ return Object.freeze([v[0] as number, v[1] as number] as const);
597
+ };
598
+ const wind = pair('wind');
599
+ const gravity = pair('gravity');
600
+ const standing = ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
601
+ ctx.env = Object.freeze({ wind: wind ?? standing.wind, gravity: gravity ?? standing.gravity });
602
+ }
603
+
604
+ /** The environment a live track's physics answers now (`setEnvironmentOn`): spine-core's initial one where none was set. */
605
+ export function environmentOf(ctx: PhysicsStepContext): PhysicsEnvironment {
606
+ return ctx.env ?? DEFAULT_PHYSICS_ENVIRONMENT;
607
+ }
608
+
609
+ /** A live track's `physicsTranslate` (issue #1416): `x` and `y` finite, or refused by name before anything moves; then `translatePhysics`. */
610
+ export function translateOn(ctx: PhysicsStepContext, x: number, y: number, who: string): void {
611
+ for (const [k, v] of [['x', x], ['y', y]] as const) {
612
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`${who} physicsTranslate's ${k} is ${String(v)}, not a finite distance in the skeleton's world`);
613
+ }
614
+ translatePhysics(ctx, x, y);
615
+ }
616
+
617
+ /** A live track's `physicsRotate` (issue #1416): `x`, `y` and `degrees` finite, or refused by name before anything moves; then `rotatePhysics`. */
618
+ export function rotateOn(ctx: PhysicsStepContext, x: number, y: number, degrees: number, who: string): void {
619
+ for (const [k, v] of [['x', x], ['y', y], ['degrees', degrees]] as const) {
620
+ if (typeof v !== 'number' || !Number.isFinite(v)) throw new CoreInputError(`${who} physicsRotate's ${k} is ${String(v)}, not a finite number`);
621
+ }
622
+ rotatePhysics(ctx, x, y, degrees);
623
+ }
624
+
578
625
  /**
579
626
  * The animation time a looping track applies its animation at (`./walk.ts`,
580
627
  * *The time*): the track time wrapped by the duration, and 0 over a duration
package/src/core/track.ts CHANGED
@@ -174,6 +174,36 @@
174
174
  * `AnimationState` updated by the step and its `Skeleton` by the clock), and
175
175
  * `CO55` that the times and the events are those of the walk without it.
176
176
  *
177
+ * ## The environment, and the rig moved from outside (issue #1416)
178
+ *
179
+ * The rig states how each physics constraint answers the world — its `wind`
180
+ * and `gravity` — and the consumer states the world. `setEnvironment({ wind,
181
+ * gravity })` sets it from the next pose on and it stands until set again,
182
+ * as spine-core's four `Skeleton` fields do: `wind` is `[windX, windY]`,
183
+ * `gravity` is `[gravityX, gravityY]`, x to the right and **y down**, and
184
+ * until set it is the runtime's own `{ wind: [1, 0], gravity: [0, 1] }`
185
+ * (`./constraints_physics.ts`, *The environment*). Either half may be left
186
+ * out to keep the one that stands. So a gust is the consumer setting `wind`
187
+ * to `[0, 0]` at rest and `[k, 0]` while it blows, and a constraint that
188
+ * states `wind: 0` does not feel it — the response is the rig's, the gust the
189
+ * scene's.
190
+ *
191
+ * `physicsTranslate(x, y)` and `physicsRotate(x, y, degrees)` are the
192
+ * runtime's `Skeleton` methods of those names: the consumer moved or turned
193
+ * the whole rig since the last pose — in the skeleton's world, y up, degrees
194
+ * counter-clockwise — and every constraint's next update sees its bone move
195
+ * by that much, so a rig walked across the screen trails what hangs from it.
196
+ * A player forwards what spine-core's `SkeletonPhysicsMovement` forwards: the
197
+ * change of its display object's position since the last frame, converted into
198
+ * the skeleton's units and axes, and the change of its rotation about the
199
+ * skeleton's origin, `physicsRotate(0, 0, Δ)`, both before the `step` that
200
+ * poses the frame.
201
+ *
202
+ * `CO56` holds the poses to spine-core 4.3.13 given the same values on its
203
+ * `Skeleton`, and holds red the readings the mapping rejects; `CO57` holds that
204
+ * nothing set — or the runtime's own values set on every step and a move of 0
205
+ * — poses what the track posed before the methods existed, bit for bit.
206
+ *
177
207
  * ## The oracle chain
178
208
  *
179
209
  * Nothing here is held to spine-core directly but the events: the live track
@@ -192,9 +222,11 @@
192
222
  * history, so a seek is "open, then step to the time".
193
223
  */
194
224
  import { CoreInputError, type CompiledDocument } from './index.ts';
195
- import { checkedClock, openRawTrack, stepRawTrack, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
225
+ import { checkedClock, environmentOf, openRawTrack, rotateOn, setEnvironmentOn, stepRawTrack, translateOn, type RawPose, type RawReset, type TrackState, type WalkMode } from './raw.ts';
196
226
  import type { LocalAdjust, TimelinePlant } from './animation.ts';
227
+ import type { PhysicsEnvironment } from './constraints_physics.ts';
197
228
  export type { AdjustableLocals, BoneLocals, LocalAdjust } from './animation.ts';
229
+ export type { PhysicsEnvironment } from './constraints_physics.ts';
198
230
 
199
231
  /** How a live track is opened: whether it loops, and where its physics is reset (`RawReset`) — both the caller's, neither with a default — the caller's adjustment of every pose's bone locals (the header's *The adjustment*), and what each step moves the physics clock by (the header's *The physics clock*). */
200
232
  export interface TrackOptions {
@@ -217,6 +249,14 @@ export interface LiveTrack {
217
249
  readonly animationTime: number;
218
250
  /** The runtime's duration of the animation (`CoreAnimationTimelines.duration`). */
219
251
  readonly duration: number;
252
+ /** Sets the world every physics constraint answers from the next pose on (the header's *The environment*): `wind` and `gravity` as spine-core's `Skeleton.windX, windY` and `gravityX, gravityY` — x right, y down — either left out to keep the one that stands. Until set, `{ wind: [1, 0], gravity: [0, 1] }`, the runtime's own. */
253
+ setEnvironment(environment: Partial<PhysicsEnvironment>): void;
254
+ /** The environment the next pose answers. */
255
+ readonly environment: PhysicsEnvironment;
256
+ /** spine-core's `Skeleton.physicsTranslate`: the consumer moved the whole rig by `(x, y)` in the skeleton's world (y up) since the last pose, and every physics constraint's next update sees it. */
257
+ physicsTranslate(x: number, y: number): void;
258
+ /** spine-core's `Skeleton.physicsRotate`: the consumer turned the whole rig by `degrees` (counter-clockwise) about `(x, y)` in the skeleton's world since the last pose. */
259
+ physicsRotate(x: number, y: number, degrees: number): void;
220
260
  }
221
261
 
222
262
  /** The two modes: a track that holds at its duration is the raw entry's walk; a looping one fires its events across the wrap and cuts its clipped rows (`live`). Neither keeps a non-finite value. */
@@ -264,6 +304,30 @@ class Track implements LiveTrack {
264
304
  return this.track.duration;
265
305
  }
266
306
 
307
+ get environment(): PhysicsEnvironment {
308
+ return environmentOf(this.track.ctx);
309
+ }
310
+
311
+ setEnvironment(environment: Partial<PhysicsEnvironment>): void {
312
+ this.live('setEnvironment');
313
+ setEnvironmentOn(this.track.ctx, environment, 'the live track\'s');
314
+ }
315
+
316
+ physicsTranslate(x: number, y: number): void {
317
+ this.live('physicsTranslate');
318
+ translateOn(this.track.ctx, x, y, 'the live track\'s');
319
+ }
320
+
321
+ physicsRotate(x: number, y: number, degrees: number): void {
322
+ this.live('physicsRotate');
323
+ rotateOn(this.track.ctx, x, y, degrees, 'the live track\'s');
324
+ }
325
+
326
+ /** A refused track refuses every call that would move it, naming the step it was refused at. */
327
+ private live(call: string): void {
328
+ if (this.refused !== null) throw new CoreInputError(`the live track was refused at step ${this.refused}, after its time and physics had moved; open the track again (${call} not made)`);
329
+ }
330
+
267
331
  step(dt: number): RawPose {
268
332
  const n = this.steps + 1;
269
333
  if (this.refused !== null) throw new CoreInputError(`the live track was refused at step ${this.refused}, after its time and physics had moved; open the track again`);
@@ -164,7 +164,8 @@ import { activeBones, CoreInputError, rawNumber, readColour, shownAttachment, ty
164
164
  import { channelAt, keyIndexAt, SLOT_COLOUR_TIMELINES, slotRowsFrom, steppedLocalsWorld, type BoneTimelineKind, type CoreKey, type LocalAdjust, type SlotBase, type SlotColourKind, type TimelinePlant } from './animation.ts';
165
165
  import { deformAt, setupArray, type CoreAttachmentTimeline } from './deform.ts';
166
166
  import { eventsFired, type CoreEventKey, type CoreEventRow } from './events.ts';
167
- import { checkedClock, openRawTracksPath, type RawPose, type RawReset, type RawTracksPath } from './raw.ts';
167
+ import { checkedClock, environmentOf, openRawTracksPath, rotateOn, setEnvironmentOn, translateOn, type RawPose, type RawReset, type RawTracksPath } from './raw.ts';
168
+ import type { PhysicsEnvironment } from './constraints_physics.ts';
168
169
  import { slotTimelinesApply } from './skins.ts';
169
170
 
170
171
  /** How the live tracks are opened: where the physics is reset (`RawReset`, the caller's, no default), the caller's adjustment of every pose's bone locals, and what each step moves the physics clock by (the header's *The physics clock*). */
@@ -228,6 +229,14 @@ export interface LiveTracks {
228
229
  current(track: number): TrackEntryView | null;
229
230
  /** The running sum of the steps taken, 0 before the first. */
230
231
  readonly trackTime: number;
232
+ /** Sets the world every physics constraint answers from the next pose on, as `LiveTrack.setEnvironment` (`./track.ts`, *The environment*). */
233
+ setEnvironment(environment: Partial<PhysicsEnvironment>): void;
234
+ /** The environment the next pose answers. */
235
+ readonly environment: PhysicsEnvironment;
236
+ /** spine-core's `Skeleton.physicsTranslate`, as `LiveTrack.physicsTranslate`. */
237
+ physicsTranslate(x: number, y: number): void;
238
+ /** spine-core's `Skeleton.physicsRotate`, as `LiveTrack.physicsRotate`. */
239
+ physicsRotate(x: number, y: number, degrees: number): void;
231
240
  }
232
241
 
233
242
  /**
@@ -1333,6 +1342,25 @@ class Tracks implements LiveTracks {
1333
1342
  return this.time;
1334
1343
  }
1335
1344
 
1345
+ get environment(): PhysicsEnvironment {
1346
+ return environmentOf(this.path.ctx);
1347
+ }
1348
+
1349
+ setEnvironment(environment: Partial<PhysicsEnvironment>): void {
1350
+ this.live('setEnvironment');
1351
+ setEnvironmentOn(this.path.ctx, environment, 'the live tracks\'');
1352
+ }
1353
+
1354
+ physicsTranslate(x: number, y: number): void {
1355
+ this.live('physicsTranslate');
1356
+ translateOn(this.path.ctx, x, y, 'the live tracks\'');
1357
+ }
1358
+
1359
+ physicsRotate(x: number, y: number, degrees: number): void {
1360
+ this.live('physicsRotate');
1361
+ rotateOn(this.path.ctx, x, y, degrees, 'the live tracks\'');
1362
+ }
1363
+
1336
1364
  private live(call: string): void {
1337
1365
  if (this.refused !== null) throw new CoreInputError(`the live tracks were refused at ${this.refused}, after their entries and physics had moved; open them again (${call} not made)`);
1338
1366
  }
package/src/validate.ts CHANGED
@@ -156,6 +156,7 @@ import type { ConstraintTargetFacts, TargetAnimation, TargetKeyArray } from './a
156
156
  import { a34ConstraintTimelineTargets } from './assertions/bodies/a34.ts';
157
157
  import { a40SlidersComposeOnASharedTarget } from './assertions/bodies/a40.ts';
158
158
  import { a10NoNanAfterStepping } from './assertions/bodies/a10.ts';
159
+ import { a52WeightedVerticesShareOneBoneOrder } from './assertions/bodies/a52.ts';
159
160
  import type { SteppedFrame, SteppedPoseFacts } from './assertions/facts/stepped_poses.ts';
160
161
 
161
162
  export type { Failure } from './assertions/harness.ts';
@@ -2460,6 +2461,11 @@ export function validate(input: ValidateInput): ValidateReport {
2460
2461
  }
2461
2462
  });
2462
2463
 
2464
+ // A52 (issue #1407) reads the raw text as A31 and A35 do: the order a weighted
2465
+ // run lists its bones in is a fact of the file the Spine editor refuses and
2466
+ // spine-core loads, so it runs whether or not the round trip does.
2467
+ check('A52_WEIGHTED_VERTICES_SHARE_ONE_BONE_ORDER', () => a52WeightedVerticesShareOneBoneOrder(verdicts, raw));
2468
+
2463
2469
  // --- A: the round trip ----------------------------------------------------
2464
2470
  // The two loaded objects come back OUT of the assertion rather than being
2465
2471
  // assigned into it. Everything below reads them, and a `let` written inside a