spine-rigc 2.13.3 → 2.14.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
@@ -38,8 +38,8 @@ Software's terms, not a licence term of this project:
38
38
  development dependency for its own gate, and so does an install with
39
39
  `spine-core` added beside it. Validation is not optional on either: the gate
40
40
  runs through the runtime where it is installed and through rigc's own validator
41
- where it is not, and the two are held to the same verdicts in CI on 50 of the
42
- 51 assertions — the official parser's own parse runs only where the runtime is.
41
+ where it is not, and the two are held to the same verdicts in CI on 49 of the
42
+ 50 assertions — the official parser's own parse runs only where the runtime is.
43
43
 
44
44
  > **If a product integrates a Spine Runtime to play rigc's output, that integration
45
45
  > is subject to the terms above** — rigc neither creates those terms nor removes
package/README.md CHANGED
@@ -61,12 +61,12 @@ it cuts both ways:
61
61
  is a development dependency: this repository uses it to hold rigc's own validator
62
62
  to the round trip's verdicts — in CI, on the public example recipes and every
63
63
  selftest call, and on a private corpus of production rigs measured before each
64
- release. Of the 51 assertions, 50 run on both; `A00_ROUNDTRIP_PARSE`, the official
64
+ release. Of the 50 assertions, 49 run on both; `A00_ROUNDTRIP_PARSE`, the official
65
65
  parser's own parse, runs only where the runtime is installed and is reported as a
66
66
  SKIP elsewhere. There, in the parse's place, the report also carries the model
67
67
  side's own two parse rules, `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES`,
68
68
  which no round-trip rule matches and the registry does not hold — so its summary
69
- totals 53 on the core entry, and its last line counts 44 rules on the model side
69
+ totals 52 on the core entry, and its last line counts 43 rules on the model side
70
70
  ([AUTHORING §5.2](docs/AUTHORING.md) has a row for each). Install it beside rigc
71
71
  (`bun add -d @esotericsoftware/spine-core`) and the same `rigc` runs the round
72
72
  trip on every build. The
@@ -581,9 +581,9 @@ first three work on any reference you have, and `bench` is a repository workflow
581
581
  and `bun run fetch-examples`. The reasoning behind them is in
582
582
  [the benchmark dossier](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
583
583
 
584
- `build` and `validate` both default to `--profile spine` — the 35 validity rules, which
584
+ `build` and `validate` both default to `--profile spine` — the 34 validity rules, which
585
585
  ask *is this valid Spine 4.3 that any runtime plays correctly?* `--profile spine-html`
586
- adds all 51: the other 16 are one renderer's policy and one canvas budget's, and they
586
+ adds all 50: the other 16 are one renderer's policy and one canvas budget's, and they
587
587
  fire on perfectly correct editor-produced Spine data, which is why they are opt-in.
588
588
  ⇒ **That reason is about foreign data and does not carry to a rig you are authoring
589
589
  yourself: author under `--profile spine-html` and read the extra 16 as findings, and
@@ -739,7 +739,7 @@ letting `A17` blame the editor for the harness's own doing.
739
739
  | 📥 **[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 |
740
740
  | 🤖 **[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 |
741
741
  | 🔬 **[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 |
742
- | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/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 51 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 |
742
+ | 🎓 **[the benchmark dossier](https://github.com/firejune/rigc/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 |
743
743
  | 📋 [LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md) · [GATE.md](https://github.com/firejune/rigc/blob/main/docs/GATE.md) · [PILOT.md](https://github.com/firejune/rigc/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 |
744
744
  | 🔬 [SURVEY_2026-08-22.md](https://github.com/firejune/rigc/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 |
745
745
  | 🧬 [GENERATIONS.md](https://github.com/firejune/rigc/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 |
@@ -789,7 +789,7 @@ quality."* All six, with their verdicts, are in
789
789
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
790
790
 
791
791
  The whole dossier — the yardstick, `diff` and `check` and what neither of them can
792
- see, every rung, the run viewer, the 51 assertions and the selftest behind them — is
792
+ see, every rung, the run viewer, the 50 assertions and the selftest behind them — is
793
793
  [docs/BENCHMARK.md](https://github.com/firejune/rigc/blob/main/docs/BENCHMARK.md).
794
794
  Live rung status is
795
795
  [docs/LADDER.md](https://github.com/firejune/rigc/blob/main/docs/LADDER.md).
package/docs/AUTHORING.md CHANGED
@@ -233,7 +233,7 @@ What the flags mean:
233
233
  | `--manifest` | a cut manifest. Only for a rig with **measured art** behind it; a foreign skeleton has none |
234
234
  | `--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 |
235
235
  | `--cuts` | the `cuts.json` `--cut` names. Required beside it — `--cut` alone is refused, with no guess at where the table lives |
236
- | `--profile` | `spine` = the 35 validity rules (**the default**) · `spine-html` = all 51, opt-in |
236
+ | `--profile` | `spine` = the 34 validity rules (**the default**) · `spine-html` = all 50, opt-in |
237
237
  | `--candidate` | `check`, `bench`, `render`, `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 |
238
238
  | `--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 |
239
239
  | `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
@@ -1947,7 +1947,7 @@ it would simply be a second root.
1947
1947
  | --- | --- | --- |
1948
1948
  | `name` | required, unique — the join key for slots, meshes and timelines | — |
1949
1949
  | `parent` | omitted only by the root bone | none |
1950
- | `length` | bone length; cosmetic in a renderer, part of a faithful reproduction | `0` |
1950
+ | `length` | bone length. Drawing reads it nowhere, but a physics constraint driving `rotate`, `shearX` or `scaleX` on this bone steps off it: the solver's lever is the tip, `length` along the bone's own x axis. With `0` there is no tip, and `A23` refuses the constraint by name (§5.2, issue #1195) | `0` |
1951
1951
  | `x`, `y` | position **local to the parent** | `0` |
1952
1952
  | `rotation` | degrees, counter-clockwise, y **up** | `0` |
1953
1953
  | `scaleX`, `scaleY` | | `1` |
@@ -3951,7 +3951,7 @@ is not an array. Every field is optional and each is the payload a firing
3951
3951
 
3952
3952
  Optional with one exception, and only meaningful for rigc's own formations:
3953
3953
  `meshSlots` and `meshTriangles` (the two halves of the mesh budget `A13` measures
3954
- against), `axisBone`, `massBone`, `detached`, `deformMayFold`, `editorRoundTrip`,
3954
+ against), `axisBone`, `massBone`, `detached`, `deformMayFold`,
3955
3955
  `consumerDrivenMix`, `idleDrivesMeshes`.
3956
3956
  Nothing in skeleton
3957
3957
  JSON records that a
@@ -4005,33 +4005,6 @@ page turning over, a cloth creasing back on itself, where the reversed winding
4005
4005
  of the field and an honest one**, but it is a loan against a fix, not a fix — and
4006
4006
  what makes it repayable is A39 measuring the ceiling the art can actually take.
4007
4007
 
4008
- 🎬 **`editorRoundTrip` is the one field here that names a CONSUMER rather than a
4009
- shape.** Write `"editorRoundTrip": true` when this rig is authored to come back
4010
- out of the Spine editor — imported, hand-edited, exported — and
4011
- `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP` refuses a physics constraint driving a
4012
- component that editor cannot hold. `true` is the only accepted value; a `false`
4013
- would be a key nothing reads.
4014
-
4015
- ⚠️ **Leaving it out is not a weaker gate, and this is the part worth reading.**
4016
- rigc's output is not wrong here: a physics constraint driving `rotate` is valid
4017
- Spine 4.3 that every runtime plays — a cowlick, a tail, an ear — so refusing it by
4018
- default would be refusing correct data on behalf of a pipeline nobody declared.
4019
- What a rig that says nothing gets instead is the **SKIP**, and the SKIP names the
4020
- constraint and the component:
4021
-
4022
- ```
4023
- SKIP A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: the rig "look" does not declare `invariants.editorRoundTrip`, so nothing here is gated against the Spine editor. What is here: physics "whip" drives rotate, and the editor's physics model holds x and y only, so a round trip returns that constraint driving nothing at all (issue #540)
4024
- ```
4025
-
4026
- 📏 **Measured, not inferred:**
4027
- three rigs, twelve constraints, predictions written before the round trip. A lone
4028
- `y` came back and `x` + `y` together came back — the rule is membership, not arity
4029
- — while a lone `rotate`, a lone `scaleX` and a lone `shearX` each came back driving
4030
- **no component at all**, and neither `scaleY` mode rescues `scaleX`. So the fix for
4031
- a refusal is to drive the constraint in `x`/`y`, or to drop the declaration if this
4032
- rig never goes near the editor. ⇒ `A23_PHYSICS_CONSTRAINT_EFFECTIVE` is the same
4033
- loss seen from the far side: it is what fires on the file the editor hands **back**.
4034
-
4035
4008
  🚫 **Do not reach for it to cover a part you have faded out.** A key whose slot
4036
4009
  draws no pixels at that key's own time is already passed over — `A39` measures
4037
4010
  that and says so (§4.11, and the `skipped` line in the `DEFORM` block). Declaring
@@ -4040,6 +4013,14 @@ visible too, which is trading a false positive for a blind spot on the same slot
4040
4013
  ⚠️ And land that alpha-0 key **before** the folding key rather than on it — the
4041
4014
  frames in between are drawn and are gated (§4.11.3).
4042
4015
 
4016
+ 🗑️ **`editorRoundTrip` was retired in issue #1196, with
4017
+ A41, the one rule that read it**, and a rig
4018
+ that still declares it is refused as an unknown key: the editor drops a physics
4019
+ constraint's `rotate`, `scaleX` and `shearX` only on a bone with no length
4020
+ (measured on Spine 4.3.23 and 4.3.26), which `A23_PHYSICS_CONSTRAINT_EFFECTIVE`
4021
+ refuses by name on every rig (§5.2), so a declaration could no longer move any
4022
+ verdict.
4023
+
4043
4024
  🎛️ **`consumerDrivenMix` names the ik and transform constraints whose mix the
4044
4025
  CONSUMER sets** — from code, at runtime — rather than any animation in this file.
4045
4026
  It is
@@ -6365,9 +6346,9 @@ prints six rows and is one failure here.
6365
6346
  `A00_MODEL_READ` and `A00_MODEL_REGIONS_ON_PAGES` — the first two rows of the
6366
6347
  table below — which stand where `A00_ROUNDTRIP_PARSE`'s parse stands and have no
6367
6348
  round-trip counterpart, so they are not in the registry. That is the whole of the
6368
- difference between the two totals: `<N>` is 53 on the core entry and 51 on `cli.ts build`,
6369
- and the core report's last line states the split — 44 rules on the model side,
6370
- which are the 42 registry assertions the model side runs and those two; the 8 restated over the emitted text;
6349
+ difference between the two totals: `<N>` is 52 on the core entry and 50 on `cli.ts build`,
6350
+ and the core report's last line states the split — 43 rules on the model side,
6351
+ which are the 41 registry assertions the model side runs and those two; the 8 restated over the emitted text;
6371
6352
  and `A00_ROUNDTRIP_PARSE`, which it reports as a SKIP.
6372
6353
 
6373
6354
  🚨 **When `A00_ROUNDTRIP_PARSE` fails, read the report as a report about A00 and
@@ -6405,7 +6386,7 @@ Fix A00 and run it again.
6405
6386
  | `A20_MESH_WEIGHTS_COHERENT` | both ◑ | a weighted vertex with no bone, a negative weight, a bone index out of range, or weights that do not sum to 1. On a rig spec's `"boneIndexing": "raw"` mesh an index out of range or a run that does not decode is refused at compile first (§5.1, issue #917), so here it names a skeleton rigc did not compile. Under `spine-html` also: an unweighted mesh, a binding at weight 0, or **a bone the mesh declares that no vertex binds** — `mesh "x" declares bone "grip_b" and none of its 25 vertices binds it; the weights reference "box", "grip_a"`. Those three are one sentence about rigc's own generators: the bone set a generated mesh declares is the bone set its weights reference, so a `controls` or `chain` name that moves nothing is a defect where a foreign mesh's is not. Fix the rig spec's `controls`/`chain`, a `segments` generator's `bones` (its `influence` line names a bone no vertex binds), or the manifest's `control_bones`. **SKIP** when the skeleton carries no mesh attachment |
6406
6387
  | `A21_MESH_RIM_PINNED` | archetype | a generated ring's rim, a ribbon's entry row, or a contour's outline (which is all of it) is not pinned to its anchor bone at weight 1. **SKIPs** by name on authored or linked geometry and on a `segments` mesh, whose outline is weighted by distance like the rest of it — none of them claims a pinned rim |
6407
6388
  | `A22_MESH_UVS_IN_UNIT_RANGE` | both | a mesh UV outside its region, or a UV array that disagrees with the vertex count. **SKIP** when the skeleton carries no mesh attachment |
6408
- | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | both | a physics constraint that drives no component, rests at `mix: 0` with **no timeline in any animation keying that `mix` above 0**, has `mass: 0`, has `strength` at or below 0 — `0` says `nothing pulls it back`, below 0 says the offset `is pushed away and grows with every step`, both read off the row's `outside` arms, which the key's refusal quotes too — or has `damping` outside `[0, 1]`, which says `` physics "C" has damping 1.5; must be inside [0, 1] — the per-step decay is `damping ** (60 * step)`, and the runtime runs this value finitely, so refusing it is rigc's call rather than the runtime's: above 1 every velocity grows on every step and the offset diverges ``, the bound read off the row the key's refusal reads; `1` and `0` are inside, since both are finite at every rate — **at rest, and on every physics timeline key**. The timeline arm reads each key through the runtime's own `PhysicsConstraint*Timeline.set`, so a keyed `mass` is judged as the `massInverse` it becomes, and the detail names the animation, the constraint, the key time, the value and the bound. Two differences between the two arms, and the runtime is the reason for both: a **key** of `mix: 0` is accepted, because `update` opens with `if (mix === 0) return;` and muting a constraint for a stretch is what a mix timeline is for — the editor's own `sack-pro` example keys it there on 24 of its 36 mix keys — and a **key** of `strength: 0` is accepted, because it releases the constraint for the span with `damping` and `inertia` still applied and the next key pulls the offset back, measured through spine-core at no NaN, a coast to a limit and a return in 54 steps. As a **setup** value `strength: 0` is still refused by the arm above, and `mix: 0` is refused only when nothing keys it above 0. The `mix` branch above is why `mix` is the one setup value a key can answer for: at rest the constraint is **inert** rather than broken, so a rig that rests muted and is keyed above 0 is refused by nothing, while a rig resting at `mass: 0` is `massInverse` Infinity before anything plays and no key reaches back into that. The detail of the refusal says both halves and how many animations were searched: `` physics "C" has mix 0 and none of the 3 animations keys its mix above 0; the runtime runs this value finitely, so refusing it is rigc's call rather than the runtime's: at 0 `update` returns before it does anything (`PhysicsConstraint.js:109-111`), so the constraint is muted — rest it above 0, or key its mix above 0 in an animation ``. ⚠️ Every reason A23 prints for a setup value is that value's row `basis` arm (§4.4's table) — an arithmetic arm names its expression (`physics "C" has massInverse Infinity; mass must be > 0 — at 0 …`), a behavioural one says it is rigc's call and what the value does. A setup `mix` **below** 0 is told the jiggle is applied inverted, not that it is muted: such a constraint is measured moving its bone by exactly the opposite of a positive mix. The search counts the unnamed global timeline for every constraint whose own `mixGlobal` is set, reads each key through the runtime's accessor, counts every sample of a Bezier between two keys as a value the timeline poses — so two keys of 0 joined by a curve lifted above 0 are a rescue, measured to move the bone — and takes an animation a slider applies like any other. It is the one reading `A36` and `A37` use as well. `inertia`, `wind`, `gravity` and the top of `mix` are bounded nowhere, at rest or keyed. `ingest` does not carry a constraint that drives no component into the spec it writes: it omits it with its timelines and reports `PHYSICS_DRIVES_NOTHING` ([INGEST §2.0](INGEST.md)), so this sentence is met on a file, never on a decompiled rebuild. **SKIP** when the skeleton declares no physics constraint — the same sentence `A36` and `A37` print for their own constraint types |
6389
+ | `A23_PHYSICS_CONSTRAINT_EFFECTIVE` | both | a physics constraint that drives no component, rests at `mix: 0` with **no timeline in any animation keying that `mix` above 0**, has `mass: 0`, has `strength` at or below 0 — `0` says `nothing pulls it back`, below 0 says the offset `is pushed away and grows with every step`, both read off the row's `outside` arms, which the key's refusal quotes too — or has `damping` outside `[0, 1]`, which says `` physics "C" has damping 1.5; must be inside [0, 1] — the per-step decay is `damping ** (60 * step)`, and the runtime runs this value finitely, so refusing it is rigc's call rather than the runtime's: above 1 every velocity grows on every step and the offset diverges ``, the bound read off the row the key's refusal reads; `1` and `0` are inside, since both are finite at every rate — **at rest, and on every physics timeline key**. The timeline arm reads each key through the runtime's own `PhysicsConstraint*Timeline.set`, so a keyed `mass` is judged as the `massInverse` it becomes, and the detail names the animation, the constraint, the key time, the value and the bound. Two differences between the two arms, and the runtime is the reason for both: a **key** of `mix: 0` is accepted, because `update` opens with `if (mix === 0) return;` and muting a constraint for a stretch is what a mix timeline is for — the editor's own `sack-pro` example keys it there on 24 of its 36 mix keys — and a **key** of `strength: 0` is accepted, because it releases the constraint for the span with `damping` and `inertia` still applied and the next key pulls the offset back, measured through spine-core at no NaN, a coast to a limit and a return in 54 steps. As a **setup** value `strength: 0` is still refused by the arm above, and `mix: 0` is refused only when nothing keys it above 0. The `mix` branch above is why `mix` is the one setup value a key can answer for: at rest the constraint is **inert** rather than broken, so a rig that rests muted and is keyed above 0 is refused by nothing, while a rig resting at `mass: 0` is `massInverse` Infinity before anything plays and no key reaches back into that. The detail of the refusal says both halves and how many animations were searched: `` physics "C" has mix 0 and none of the 3 animations keys its mix above 0; the runtime runs this value finitely, so refusing it is rigc's call rather than the runtime's: at 0 `update` returns before it does anything (`PhysicsConstraint.js:109-111`), so the constraint is muted — rest it above 0, or key its mix above 0 in an animation ``. ⚠️ Every reason A23 prints for a setup value is that value's row `basis` arm (§4.4's table) — an arithmetic arm names its expression (`physics "C" has massInverse Infinity; mass must be > 0 — at 0 …`), a behavioural one says it is rigc's call and what the value does. A setup `mix` **below** 0 is told the jiggle is applied inverted, not that it is muted: such a constraint is measured moving its bone by exactly the opposite of a positive mix. The search counts the unnamed global timeline for every constraint whose own `mixGlobal` is set, reads each key through the runtime's accessor, counts every sample of a Bezier between two keys as a value the timeline poses — so two keys of 0 joined by a curve lifted above 0 are a rescue, measured to move the bone — and takes an animation a slider applies like any other. It is the one reading `A36` and `A37` use as well. `inertia`, `wind`, `gravity` and the top of `mix` are bounded nowhere, at rest or keyed. `ingest` does not carry a constraint that drives no component into the spec it writes: it omits it with its timelines and reports `PHYSICS_DRIVES_NOTHING` ([INGEST §2.0](INGEST.md)), so this sentence is met on a file, never on a decompiled rebuild. 🦴 **A lever drive on a bone with no lever** (issue #1195): a constraint driving `rotate`, `shearX` or `scaleX` on a bone whose `length` is 0 (or states none) is refused, at rest, with one sentence per reading. `rotate` and `shearX` chase the bone's tip, so with no tip the solver aims the bone at a **world** direction — measured through rigc's poser on a chain held still for 3 s, a length-0 bone turns 53.204° from a 90° rest and holds there, where length 0.01 moves 0.0000: `` physics "C" drives rotate on bone "B" whose length is 0 — with no tip the solver aims the bone at world angle 0 and holds it there; give "B" a length or drive x/y instead ``. The rest angle is not read: a bone resting at world 0° does not move, but only until a key or a parent turns it. `scaleX` divides the motion along the bone by its length and adds nothing at 0, so the drive does nothing: `` physics "C" drives scaleX on bone "B" whose length is 0, which the solver divides the motion by, so it drives nothing; give "B" a length ``. Fix in the **rig spec**: give the bone its `length` (§3.2), measured off the art it carries, or, for the first sentence, drive the constraint in `x`/`y` in the **motion spec** instead. `x` and `y` read the bone's origin and never its tip, so a constraint driving only those on a length-0 bone is not refused; the editor's own example exports carry them there. The Spine editor's export drops `rotate`, `scaleX` and `shearX` from a physics constraint on a zero-length bone and keeps them on any bone with a length — measured on 4.3.23 and 4.3.26, issue #1196 — so what this clause refuses is also what the editor would silently lose. **SKIP** when the skeleton declares no physics constraint — the same sentence `A36` and `A37` print for their own constraint types |
6409
6390
  | `A24_AXIS_SPACE_STROKE` | archetype | a bone under the rig's `axisBone` was keyed with a screen-space Y component, or the axis bone itself was keyed. **SKIP** when the rig declares no axis bone, and also when no animation keys that bone or anything under it |
6410
6391
  | `A25_DETACHED_BONE_PARENTAGE` | archetype | a bone the rig declares `detached` is a descendant of the bone it must never hang under |
6411
6392
  | `A26_SLOT_DRAW_ORDER` | archetype | the emitted slots are not the rig's slot table — a slot is out of order, is not in the table at all, or is in the table and missing from the skeleton (§3.3). **SKIP** when the rig declares no canonical slot order. ⚠️ A skeleton with **no** slot beside a rig that declares some is **not** a skip, and it is the one rule in this family where an empty loop is not a vacuous pass: the completeness clause reads it as every declared slot lost and names them |
@@ -6423,7 +6404,6 @@ Fix A00 and run it again.
6423
6404
  | `A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED` | both | a bone or constraint a skin activates that is not `skinRequired` (the list changes nothing), or one that is `skinRequired` and no skin activates (it is never active). Two keys in two places, and only together do they mean "this belongs to that skin" (§3.4.1). **SKIP** when no skin activates anything and nothing is `skinRequired` |
6424
6405
  | `A39_DEFORM_KEEPS_TRIANGLE_WINDING` | archetype | a `deform` key reverses a triangle's winding, so the mesh has locally turned inside out and draws its texture backwards there (§4.11). The detail names the animation, the slot, the attachment, the key index and time, and each reversed triangle with its vertex triple and its signed area before and after. Measured at the key's **own** time, deformed against the same posed bones undeformed, so a mirrored slot bone cancels and a wrong *projection* with intact winding is correctly silent. A projection past its fold angle is the usual cause — [FACE.md §4.2](FACE.md) has the closed form. Legitimate art does fold, so declare `invariants.deformMayFold` (§3.7) for a slot that folds on purpose. ⚠️ A key whose slot **draws no pixels at that key's own time** — faded to alpha exactly 0, or showing another attachment — is measured and then passed over, because "draws its texture backwards" is false when nothing of it is drawn; the key is named on the stats line (`deformKeysNotDrawn`) and in the `DEFORM` block, never silently. The bar is **exactly 0**: at alpha 0.5 the fold is still refused and the alpha is in the message. It is per key and per time, so the same slot folding at full alpha in another animation is still refused. ⚠️ And the **spans between** consecutive keys are scanned too (§4.11.3): the runtime interpolates, so a deform inside its fold angle at every key can be past it in between. That refusal is its own sentence — `BETWEEN key 0 (t=0s) and key 1 (t=0.5s), at t=…` — with the time solved for in closed form and then posed and measured like any key, alpha read at that same moment. `deformSpansScanned` says on every green build that the scan ran. ⚠️ And the **frame** it poses in is the one the animation is reached in (§4.11.4): on a track when nothing applies it, and otherwise once per **slider**, with that slider's mapping inverted and its bone driven until the runtime selects the key's own time — because a slider picks the time, so the two are one number and posing them independently is a frame that never occurs. The frame is on every `DEFORM` line, on the stats line as `deformFrames`, and in the refusal itself when it is not the track. A key at a time **no dial value selects** is measured in the frame the runtime does land on, left out of `deformKeysMeasured` and named as `deformKeysUnreachable`/`deformUnreachable` — never refused and never silent. ⚠️ And the **skin** it poses in is the one the timeline is keyed on (§4.11.5), since a deform's address is a `skin / slot / attachment` triple: the pose wears that skin, which also switches on any `skin: true` bone or constraint it activates, and the "nothing is drawn" sentence names the skin it was read under. **SKIP** when no animation carries a deform timeline, when nothing keyed has triangles, when every mesh keyed is exempt, when every key measured draws no pixels or is unreachable *and no span between them folds where anything is drawn*, or when there is no rig info at all |
6425
6406
  | `A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET` | both | two or more sliders whose animations key the same timeline, where a later one is not `additive` — it writes that property outright at `mix: 1` and every earlier slider on it is dead (§3.5.2). Also fires when the shared timeline **cannot** be applied additively (a slot colour, an attachment swap, a draw order, an ik mix, a path's `spacing`, most physics properties), where `"additive": true` is not the fix and one of the two has to go. ⭐ Which of the two it is, is **posed rather than read off `Timeline.additive`**: the shared timeline is applied twice with `add` set and the detail says what it did (two classes declare that flag falsely about themselves: a path constraint's `mix` and a slider's `time` compose although the flag says they do not). The detail names the bone or slot and the property, every slider keying it in `constraints` order with its flag, which one wins today, and the class that was posed. Four shapes are deliberately not findings: a slider below `mix: 1` or with its `mix` keyed (the apply is then a lerp from the current pose, not an overwrite), two `skinRequired` sliders no skin activates together, two sliders on different properties, and a shared timeline that writes **nothing a pose holds** — an `events` timeline fires no event under a slider (`firedEvents` is null), so neither slider has anything there for the other to erase. **SKIP** when fewer than two sliders are at full authority; a PASS means two were compared |
6426
- | `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP` | both | a physics constraint driving a component the **Spine editor** cannot hold, on a rig that declared `invariants.editorRoundTrip` (§3.7). The editor's physics model holds `x` and `y` only, with no cap on how many at once, so a constraint driving `rotate`, `scaleX` or `shearX` is imported, exported and handed back driving **nothing** — measured over three rigs and twelve constraints with the predictions written first. The detail names the constraint and each component. ⚠️ rigc's own output is correct — every runtime plays a rotation jiggle — so this is opt-in and the default is *not* silence: on a rig that declares nothing it **SKIPs**, and the SKIP names the constraint and the component anyway, so an author learns without having asked. Fix by driving the constraint in `x`/`y`, or by dropping the declaration if the rig never goes near the editor. Disjoint from `A23_PHYSICS_CONSTRAINT_EFFECTIVE` by construction: A23 refuses an **empty** driven set, which is what comes back from the editor, and this refuses a non-empty one that will not survive going in. **SKIP** also when the rig declares the editor and carries no physics constraint at all |
6427
6407
  | `A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER` | both | a slider whose animation keys a property of a constraint **at or before it** in `constraints` (§3.5.2) — a slider's `mix` or `time`, an ik or transform mix, a path `position`, `spacing` or `mix`, any physics value. That array is the update order for every kind, and each constraint reads its own applied pose when its turn comes — `Slider.update` takes `mix` as the alpha it applies with and `time` as the time it applies at, `PhysicsConstraint.update` returns on `mix` 0 before reading the rest — so the key lands after the only read of it and `Posed.resetConstrained` discards it before the next frame: what the driven constraint drives is dead at every position of the driving dial, although its pose still holds the number. The detail names the slider, the driven constraint with its kind, both array indices, the property, the runtime class whose `update` reads it, and the animation the key sits in. Fix by moving the driver earlier, or by keying that property from a slider that already is. **The two indices equal is the same failure**: a slider cannot key its own `mix` or `time`, and one muted at setup that keys its own `mix` up never applies anything at all — `A37` is silent there, because it asks whether *an* animation keys the mix and not which one. **Two shapes it deliberately leaves out**, both measured: a `physics` `reset` key, which fires on a crossed frame time and so never fires from a slider at all, in either order — the reorder would repair nothing; and a physics timeline naming no constraint, which is every physics constraint declaring that property global and IS refused for the ones already run. Disjoint from `A40` by construction: `A40` asks who writes a shared property last and excludes every slider whose `mix` is keyed, this asks whether anything reads what was written. **SKIP** when the skeleton declares no slider, and when no slider's animation keys a constraint property — that SKIP names any `reset` keys it found — a pass means a driver and a driven were compared |
6428
6408
  | `A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN` | both | a slot's `dark` (§3.3) or an `rgba2` / `rgb2` timeline (§4.4) that the runtime does not hold as the file states it. Three shapes, all of which parse in silence: a `dark` the slot reader **drops** — it takes the field through a truthiness test, so `""` is discarded without a word and the slot renders with one colour; a `dark` that is **not six hex digits** — `Color.setFromString` slices fixed offsets and stores whatever `parseInt` gives back, so `"4020"` loads a channel of `NaN`; and an `rgba2` or `rgb2` timeline on a slot with **no `dark` at all**, where the runtime allocates no dark colour and the first `state.apply` throws in the consumer's process. The keyed half is read by posing: the animation is stepped to each key's own time — at the key **as the runtime stores it**: spine-core keeps key times as 32-bit floats, so a key at `0.2` is posed at `0.20000000298…`, the later of the two, and not one float step before it, where a first key still shows the setup value and a stepped key the one before, and the posed `color` and `darkColor` are compared against the hex the key states, to half a quantisation step (`1/510`). The detail names the slot, the value found and the value required. ⚠️ The required value is parsed **here** and not through `Color.fromString`, because a check that read it out of the parser it is checking would agree with that parser whatever it did. `compile.ts` refuses the third shape outright in a rig rigc builds; this is the same fact held against a skeleton it did not write. An `rgb2` key's light colour is compared over its three channels only: the light alpha is not its to state, and it is left where it was. **SKIP** when no slot declares a `dark` and no animation keys an `rgba2` or `rgb2` — there is then no two-colour tint to read back |
6429
6409
  | `A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN` | both | a **linked mesh** (§3.4) — `type: "linkedmesh"`, or a `type: "mesh"` carrying `source` — that also states `uvs`, `triangles`, `vertices`, `hull` or `edges`. The parser returns from the `source` branch before `readVertices` (`SkeletonJson.ts:582-586`), so those keys are read by **nothing at all** and `setSourceMesh` fills the attachment with the source's arrays instead: the file says one mesh and every runtime draws another, in silence. The detail names the attachment by skin, slot and placeholder, every key it states, the `source` and where the parser looks for it — the two defaults spelled out, because an omitted `skin` is the **default** skin rather than the one the link is written in — and the shape the keys describe beside the shape the attachment loaded. ⚠️ **`width`/`height` are not part of this.** `setSourceMesh` overwrites both with the source's, so they are as dead at runtime — but the parser reads them (`:569-570`), the format carries them on a link and rigc emits them, so refusing them would refuse every link rigc writes (§3.4). `compile.ts` refuses the same shape outright in a rig rigc builds (§5.1); this is that fact held against a skeleton it did not write, and `ingest` reports it as `ATTACHMENT_LINK_GEOMETRY` ([INGEST §2.0](INGEST.md)). **SKIP** when no attachment in the skeleton takes its geometry from another — which is almost every skeleton, so a pass here means a link was read |
package/docs/FACE.md CHANGED
@@ -1347,14 +1347,26 @@ which declares no constraints at all and so can carry no slider. What it found:
1347
1347
  omitting a default — which is why `A23_PHYSICS_CONSTRAINT_EFFECTIVE` refuses it
1348
1348
  by name. Independent of the ordering.
1349
1349
 
1350
- ✅ **Why, measured.** It is not elision and not a defect in one field:
1351
- the editor's physics model holds `x` and `y` and nothing else, with no limit on
1352
- how many at once. Three rigs, twelve constraints, predictions written before the
1353
- round trip — a lone `y` came back, `x` and `y` together came back, and a lone
1354
- `rotate`, a lone `scaleX` and a lone `shearX` each came back as **no components
1355
- at all**, with every constraint's fixed-point `strength` returning exactly so a
1356
- silent harness failure could not read as a finding. ⇒ **A rotation-driven
1357
- jiggle does not survive the editor, and no `scaleY` mode substitutes for it.**
1350
+ ✅ **Why, measured** — and corrected. It is not elision and not a defect in
1351
+ one field. Issue #540 measured three rigs and twelve constraints, predictions
1352
+ written before the round trip: a lone `y` came back, `x` and `y` together came
1353
+ back, and a lone `rotate`, a lone `scaleX` and a lone `shearX` each came back as
1354
+ **no components at all**, with every constraint's fixed-point `strength`
1355
+ returning exactly. It read that as *"the editor's physics model holds `x` and
1356
+ `y` and nothing else"*, and that reading was wrong.
1357
+
1358
+ 🔁 **Corrected 2026-10-06 (issue #1196), on Spine 4.3.23 and 4.3.26:** what
1359
+ decides the loss is the **bone's length**, not the component. The editor's own
1360
+ example export `sack-pro` keeps 18 of 18 `rotate` constraints through the same
1361
+ import and export, and deleting the `length` of one of its bones loses `rotate`
1362
+ on exactly that one constraint. `look` with a 40-unit `ahoge_whip` keeps
1363
+ `rotate`, `scaleX` and `shearX` each; at 0.01 and 1 `rotate` is kept too; with no
1364
+ length all three are lost, on both versions. `look`'s `ahoge_whip` was emitted
1365
+ with no length; #540's rigs were not at hand to read, but every row it reported
1366
+ is reproduced here at length 0. ⇒
1367
+ **A rotation-driven jiggle survives the editor on a bone that has a length**,
1368
+ and `A23_PHYSICS_CONSTRAINT_EFFECTIVE` refuses one on a bone that has none
1369
+ (issue #1195).
1358
1370
 
1359
1371
  ✅ **And the locus is measured: the loss is at EXPORT** — against Spine
1360
1372
  4.3.26 and without decoding the project format. `gallery/look`'s build and three variants of its
@@ -1371,18 +1383,17 @@ which declares no constraints at all and so can carry no slider. What it found:
1371
1383
  the exporter wrote nothing for it. It is the editor's **writer**, not its
1372
1384
  reader, and not rigc's emitter.
1373
1385
 
1374
- ✅ **And the gate says so before the trip, not after.** A face rig that is
1375
- authored to come back out of the editor declares
1376
- `"invariants": { "editorRoundTrip": true }` (AUTHORING §3.7) and
1377
- `A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP` refuses the constraint by name at build
1378
- time, with the fix in the message: drive it in `x`/`y`, or drop the declaration.
1379
- ⚠️ A rig that declares nothing is **not** silent either — A41 SKIPs and the skip
1380
- names the constraint and the component, which is the whole reason the rule is
1381
- opt-in rather than default-off. rigc's own output is not wrong here: a
1382
- rotation jiggle is valid Spine 4.3 that every runtime plays, and refusing it for
1383
- everybody would be refusing correct data on behalf of one consumer. ⇒ A23 and
1384
- A41 are the same loss from opposite sides of the trip: A41 fires on what goes
1385
- in, A23 on what comes back.
1386
+ The importer stores the value on a zero-length bone too, at 4.3.23 as well as
1387
+ 4.3.26 (issue #1196: `rotate: 0.234375` lands as `3e 70 00 00` in the project,
1388
+ and the JSON and binary exports both omit it — the binary export of a project
1389
+ holding `rotate: 1` is byte-identical to one holding NaN).
1390
+
1391
+ 🗑️ **A41, the rule that gated the editor round trip, and its declaration
1392
+ `invariants.editorRoundTrip` were retired in issue #1196.** They gated the
1393
+ components rather than the bone, so they refused rotation physics the editor
1394
+ keeps, and once A23 refuses those components on a zero-length bone a rig that
1395
+ passes it has nothing the editor's export drops — the declaration could no
1396
+ longer move a verdict.
1386
1397
  - 🔸 Unexplained, and **unreproduced by anything in this tree**: `diff` reports
1387
1398
  `animations.curve_kinds` moved on **196 of 200** keys in every round trip taken,
1388
1399
  the clean one included. Visually small once the ordering is fixed — but it is
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "2.13.3",
3
+ "version": "2.14.0",
4
4
  "description": "AI-authored Spine 2D rigging and animation, verified before it is written. rigc compiles a rig spec into Spine 4.3 skeleton data, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Output imports into the Spine editor. Ships as an agent skill.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -173,6 +173,56 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
173
173
  if (!components.length) {
174
174
  fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} drives no component; it parses and does nothing`);
175
175
  }
176
+ // --- a lever drive on a bone with no lever (issue #1195) ---------------
177
+ //
178
+ // `rotate`, `shearX` and `scaleX` are stepped off the bone's tip,
179
+ // `length·(a, c)` (`stepPhysics` in `src/core/constraints_physics.ts`,
180
+ // held to spine-core at tolerance 0); `x` and `y` read only the origin, so
181
+ // they are not refused here — the editor's own example exports and
182
+ // rigc's public builds carry them on bones of length 0 and they step as
183
+ // authored. The two lever readings fail in opposite directions, so they
184
+ // print two sentences:
185
+ //
186
+ // 📏 rotate / shearX: the offset chases `atan2(dy + ty, dx + tx)`, and with
187
+ // no tip and no motion that is `atan2(0, 0)` = 0 — a WORLD direction —
188
+ // where any tip at all makes it the bone's own previous direction.
189
+ // Measured through the core on a four-bone chain held still for 3 s
190
+ // (inertia 0.5, strength 100, damping 0.85, weight 0.2794): at length 0
191
+ // the bone turns 26.602° from a 45° rest, 53.204° from 90°, 106.408° from
192
+ // 180°, and holds there; at length 0.01 it moves 0.0000. The swing under
193
+ // motion, by contrast, is continuous in the length (0.914° at 36.5 up to
194
+ // 64.934° at 0 on one bounce) — that is the author's tuning, not a defect,
195
+ // and this clause does not judge it.
196
+ //
197
+ // ⚠️ The rest ANGLE is not read. The same probe resting at world 0° moves
198
+ // 0.000° at length 0 — but only because the bone already points where the
199
+ // solver aims it. A key that turns the bone, or a parent that turns it,
200
+ // breaks that, so a length-0 bone resting at 0° is the same defect waiting
201
+ // for its first rotation, and refusing it by the angle would refuse a
202
+ // pose rather than a rig.
203
+ //
204
+ // 📏 scaleX: the along-bone motion is divided by `length·|x column|` and
205
+ // nothing is added when that is 0, so the drive does nothing — every
206
+ // length-0 rung of the same probe read 0.0000, where 0.01 threw the tip
207
+ // 806 units. It is this assertion's first clause reached through the bone
208
+ // rather than the component fields.
209
+ if (physics.boneLength === 0) {
210
+ const turns = components.filter((k) => k === 'rotate' || k === 'shearX');
211
+ if (turns.length) {
212
+ fail(
213
+ 'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
214
+ `${where} drives ${turns.join(' and ')} on bone "${physics.bone}" whose length is 0 — with no tip the solver aims the bone ` +
215
+ `at world angle 0 and holds it there; give "${physics.bone}" a length or drive x/y instead`,
216
+ );
217
+ }
218
+ if (components.includes('scaleX')) {
219
+ fail(
220
+ 'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
221
+ `${where} drives scaleX on bone "${physics.bone}" whose length is 0, which the solver divides the motion by, so it drives ` +
222
+ `nothing; give "${physics.bone}" a length`,
223
+ );
224
+ }
225
+ }
176
226
  const pose = physics.setup;
177
227
  // The four bounded fields, each judged by its `PHYSICS_POSE_RULES` row.
178
228
  // The wording is per-field and stays so: "it is muted" and "nothing
@@ -14,29 +14,21 @@ import type { ConstraintFacts, ConstraintTimeline } from './facts/constraints.ts
14
14
  import type { RigInfo } from '../types.ts';
15
15
 
16
16
  /**
17
- * Every component a physics constraint can drive (`PhysicsConstraintData`), and
18
- * the subset the **Spine editor** models.
17
+ * Every component a physics constraint can drive (`PhysicsConstraintData`), the
18
+ * vocabulary A23 reads when it asks whether a constraint drives anything.
19
19
  *
20
- * ⭐ One list, read by both A23 and A41, because the relationship between those
21
- * two rules is the thing most worth keeping true: A23 fires when the driven set
22
- * is EMPTY, A41 when it contains something outside `EDITOR_PHYSICS_COMPONENTS`.
23
- * Written against one array those conditions cannot both hold on one constraint,
24
- * and a reader can see that they cannot. Two copies of the vocabulary could
25
- * drift into overlapping, and a rig refused twice for one fact is a report that
26
- * has stopped saying what is wrong with it.
27
- *
28
- * 📏 The subset is measured rather than read off a document, and it is the whole
29
- * of issue #540: three rigs, twelve constraints, predictions recorded before the
30
- * round trip and scored by the code that printed them. A lone `y` came back, `x`
31
- * and `y` together came back — so the rule is membership and not arity — and a
32
- * lone `rotate`, a lone `scaleX` and a lone `shearX` each came back driving no
33
- * component at all, with neither `scaleY` mode rescuing `scaleX`. Every
34
- * constraint carried a fixed-point `strength` and all twelve returned exactly,
35
- * so the rows that reported nothing were reading live data. Measured on Spine
36
- * 4.3.26 Professional.
20
+ * 📏 It stood beside a second set, the components "the Spine editor models",
21
+ * read by A41 (the editor round-trip rule) and measured in issue #540 as
22
+ * `x` and `y`. Issue #1196 measured what that was really seeing, on Spine 4.3.23
23
+ * and 4.3.26: the editor imports a physics constraint's `rotate`, `scaleX` and
24
+ * `shearX` into its project and its JSON and binary exports omit them **only on
25
+ * a bone with no length** — on any bone with a length (0.01, 1, 40 tried) all
26
+ * three come back, and the editor's own example export keeps 18 of 18 `rotate`
27
+ * constraints through the same trip. A23 refuses those components on a
28
+ * zero-length bone (issue #1195), so a rig that passes it has nothing the editor drops, and
29
+ * A41, its declaration and its set were retired rather than reworded.
37
30
  */
38
31
  export const PHYSICS_COMPONENTS = ['x', 'y', 'rotate', 'scaleX', 'shearX'] as const;
39
- export const EDITOR_PHYSICS_COMPONENTS: ReadonlySet<(typeof PHYSICS_COMPONENTS)[number]> = new Set(['x', 'y'] as const);
40
32
 
41
33
  /**
42
34
  * The half of a muted-at-rest refusal that says what was searched, and how
@@ -2,7 +2,7 @@
2
2
  * The constraints and the timelines that key them, as the bodies that ask
3
3
  * "does this constraint do anything" read them (issue #1025, step 4c of
4
4
  * #380) — the census's F12 to F17 and F19 with R1 and R2, for A23, A36, A37,
5
- * A41, A42, A47 and A48.
5
+ * A42, A47 and A48.
6
6
  *
7
7
  * ⭐ **The order is the runtime's update order**, which is the file's
8
8
  * `constraints` array and the document's: a body that prints one line per
@@ -56,7 +56,19 @@ export interface ConstraintEntry {
56
56
  readonly name: string;
57
57
  /** The runtime class the constraint updates as (`IkConstraint`, `Slider`, …) — the word A42's sentence names `update` on. */
58
58
  readonly runtimeClass: string;
59
- readonly physics?: { readonly bone: string; readonly components: PhysicsComponents; readonly setup: PhysicsSetup; readonly step: number };
59
+ readonly physics?: {
60
+ readonly bone: string;
61
+ /**
62
+ * The bone's `length`, as the runtime holds it after its parse (0 where the
63
+ * file leaves it out) — the lever a `rotate`, `shearX` or `scaleX` drive is
64
+ * stepped with (issue #1195): the tip `length·(a, c)` the rotation chases,
65
+ * and the radius the along-bone motion is divided by for `scaleX`.
66
+ */
67
+ readonly boneLength: number;
68
+ readonly components: PhysicsComponents;
69
+ readonly setup: PhysicsSetup;
70
+ readonly step: number;
71
+ };
60
72
  readonly path?: { readonly bones: readonly string[]; readonly slot: string; readonly setup: { readonly mixRotate: number; readonly mixX: number; readonly mixY: number } };
61
73
  readonly slider?: {
62
74
  /** The animation it applies — its name, how many timelines it carries and its duration — or `null` when it applies none. */
@@ -118,7 +130,7 @@ export interface ConstraintTimeline {
118
130
  readonly posed?: (value: number) => number;
119
131
  }
120
132
 
121
- /** What A23, A36, A37, A41, A42, A47 and A48 read. */
133
+ /** What A23, A36, A37, A42, A47 and A48 read. */
122
134
  export interface ConstraintFacts {
123
135
  /** How many animations the skeleton declares. */
124
136
  readonly animations: number;
@@ -88,7 +88,6 @@ export const ASSERTION_KIND: Readonly<Record<string, AssertionKind>> = {
88
88
  A38_SKIN_MEMBERS_ARE_SKIN_REQUIRED: 'validity',
89
89
  A39_DEFORM_KEEPS_TRIANGLE_WINDING: 'archetype',
90
90
  A40_SLIDERS_COMPOSE_ON_A_SHARED_TARGET: 'validity',
91
- A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP: 'validity',
92
91
  A42_DRIVEN_CONSTRAINTS_UPDATE_AFTER_THEIR_DRIVER: 'validity',
93
92
  A43_TWO_COLOR_TINT_LOADS_AND_POSES_AS_WRITTEN: 'validity',
94
93
  A44_LINKED_MESH_STATES_NO_GEOMETRY_OF_ITS_OWN: 'validity',
@@ -103,6 +103,8 @@ function entryOf(doc: CompiledDocument, kind: ConstraintKind, name: string, reco
103
103
  ...base,
104
104
  physics: {
105
105
  bone: record.bone,
106
+ // The length the core's step is handed (`bone(state, c.bone).length ?? 0` in `src/core/constraints.ts`): the parser's 0 where the document leaves it out.
107
+ boneLength: doc.bones.find((b) => b.name === record.bone)?.length ?? 0,
106
108
  components: { x: record.x, y: record.y, rotate: record.rotate, scaleX: record.scaleX, shearX: record.shearX },
107
109
  setup: { mix: record.mix, massInverse: record.massInverse, strength: record.strength, damping: record.damping },
108
110
  step: record.step,
@@ -45,7 +45,6 @@ void MESH_KINDS_COMPLETE;
45
45
  type Check = (value: unknown, at: string, problems: string[]) => void;
46
46
  const say = (problems: string[], at: string, value: unknown, what: string): void => void problems.push(`${at} is ${JSON.stringify(value) ?? 'absent'}, not ${what}`);
47
47
  const str: Check = (v, at, p) => (typeof v === 'string' ? undefined : say(p, at, v, 'a string'));
48
- const bool: Check = (v, at, p) => (typeof v === 'boolean' ? undefined : say(p, at, v, 'true or false'));
49
48
  const num: Check = (v, at, p) => (typeof v === 'number' && Number.isFinite(v) ? undefined : say(p, at, v, 'a finite number'));
50
49
  const nullable = (inner: Check): Check => (v, at, p) => (v === null ? undefined : inner(v, at, p));
51
50
  const list = (inner: Check, length?: number): Check => (v, at, p) => {
@@ -74,7 +73,6 @@ const RIG_FIELDS: Readonly<Record<keyof RigInfo, Check>> = {
74
73
  meshDeclaredBones: record(list(str)),
75
74
  meshSoftBones: record(str),
76
75
  deformMayFold: list(str),
77
- editorRoundTrip: bool,
78
76
  consumerDrivenMix: list(shape({ type: oneOf(['ik', 'transform']), constraint: str, why: str })),
79
77
  idleDrivesMeshes: nullable(str),
80
78
  basePlates: list(str),
@@ -85,7 +85,6 @@ import { a28RibbonRowsShareWeights } from '../bodies/a28.ts';
85
85
  import { a33VertexAttachmentGeometry } from '../bodies/a33.ts';
86
86
  import { a36PathConstraintEffective } from '../bodies/a36.ts';
87
87
  import { a37SliderConstraintEffective } from '../bodies/a37.ts';
88
- import { a41PhysicsSurvivesEditorRoundTrip } from '../bodies/a41.ts';
89
88
  import { a42DrivenConstraintsUpdateAfterTheirDriver } from '../bodies/a42.ts';
90
89
  import { a44LinkedMeshStatesNoGeometryOfItsOwn } from '../bodies/a44.ts';
91
90
  import { a47IkConstraintNotMutedThroughout } from '../bodies/a47.ts';
@@ -294,7 +293,6 @@ export const MOVED_ASSERTIONS: readonly MovedAssertion[] = [
294
293
  { code: 'A22_MESH_UVS_IN_UNIT_RANGE', run: (v, read, _input, supply) => a22MeshUvsInUnitRange(v, supply.meshes(read)), unread: SKIP_NO_MODEL },
295
294
  { code: 'A39_DEFORM_KEEPS_TRIANGLE_WINDING', run: (v, read, input, supply) => a39DeformKeepsTriangleWinding(v, supply.deformSurvey(read), supply.rigInfo(read, input)), unread: SKIP_NO_MODEL },
296
295
  { code: 'A23_PHYSICS_CONSTRAINT_EFFECTIVE', run: (v, read, _input, supply) => a23PhysicsConstraintEffective(v, supply.constraints(read), supply.meshes(read)), unread: SKIP_NO_MODEL },
297
- { code: 'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP', run: (v, read, input, supply) => a41PhysicsSurvivesEditorRoundTrip(v, supply.constraints(read), supply.rigInfo(read, input)), unread: SKIP_NO_MODEL },
298
296
  { code: 'A36_PATH_CONSTRAINT_EFFECTIVE', run: (v, read, _input, supply) => a36PathConstraintEffective(v, supply.constraints(read)), unread: SKIP_NO_MODEL },
299
297
  { code: 'A37_SLIDER_CONSTRAINT_EFFECTIVE', run: (v, read, _input, supply) => a37SliderConstraintEffective(v, supply.constraints(read)), unread: SKIP_NO_MODEL },
300
298
  { code: 'A47_IK_CONSTRAINT_NOT_MUTED_THROUGHOUT', run: (v, read, input, supply) => a47IkConstraintNotMutedThroughout(v, supply.constraints(read), supply.rigInfo(read, input)), unread: SKIP_NO_MODEL },
package/src/compile.ts CHANGED
@@ -837,13 +837,13 @@ const SKIN_ORDER: OrderedCollection = {
837
837
  * choosing a member of it.
838
838
  *
839
839
  * It is deliberately **not** conditional on anything: not on the rig declaring a
840
- * slider, and not on the rig declaring the editor as a consumer. A slider is what
841
- * makes the difference bite today and `invariants.editorRoundTrip` is how a rig
842
- * says the editor is downstream, but the emitted order is a claim about the
843
- * editor either way, and a check that only ran for some rigs would make the claim
844
- * hold for some and not others with nothing in the file saying which — adding the
845
- * slider, or the declaration, would then be the edit that refuses a rig that built
846
- * yesterday. What issue #543 changed is the size of what is claimed, not who it is
840
+ * slider, and not on whether the editor is downstream of it, which a rig spec
841
+ * does not state (the one declaration that did, `invariants.editorRoundTrip`,
842
+ * was retired in issue #1196). A slider is what makes the difference bite today,
843
+ * but the emitted order is a claim about the editor either way, and a check that
844
+ * only ran for some rigs would make the claim hold for some and not others with
845
+ * nothing in the file saying which — adding the slider would then be the edit
846
+ * that refuses a rig that built yesterday. What issue #543 changed is the size of what is claimed, not who it is
847
847
  * claimed for.
848
848
  *
849
849
  * ⚠️ `what` is a parameter and not a second copy of this walk because issue #541
@@ -7551,7 +7551,6 @@ function buildRigInfo(
7551
7551
  meshDeclaredBones,
7552
7552
  meshSoftBones,
7553
7553
  deformMayFold,
7554
- editorRoundTrip: rig.invariants?.editorRoundTrip === true,
7555
7554
  // `parseRigSpec` resolved every entry against the rig's own constraints.
7556
7555
  consumerDrivenMix: (rig.invariants?.consumerDrivenMix ?? []).map((e) => ({ type: e.type, constraint: e.constraint, why: e.why })),
7557
7556
  // `parseRigSpec` refused every shape but a non-blank `why`.
package/src/rig.ts CHANGED
@@ -315,7 +315,12 @@ export interface RigBone {
315
315
  name: string;
316
316
  /** Omitted only by the skeleton's single root bone. */
317
317
  parent?: string;
318
- /** Default 0. Cosmetic in a renderer; part of a faithful reproduction. */
318
+ /**
319
+ * Default 0. Drawing reads it nowhere, but a physics constraint driving
320
+ * `rotate`, `shearX` or `scaleX` on this bone steps off its tip — `length`
321
+ * along the bone's x axis is the solver's lever — so a length of 0 there is
322
+ * refused by `A23_PHYSICS_CONSTRAINT_EFFECTIVE` (issue #1195).
323
+ */
319
324
  length?: number;
320
325
  /** Local to the parent. Default 0. Supplied by `from` when that is given. */
321
326
  x?: number;
@@ -1694,31 +1699,6 @@ export interface RigInvariants {
1694
1699
  * second kind, and names the issue.
1695
1700
  */
1696
1701
  deformMayFold?: RigDeformFoldExemption[];
1697
- /**
1698
- * Declare that this rig is authored to come back out of the **Spine editor** —
1699
- * imported, hand-edited, exported — and gate what that consumer cannot hold
1700
- * (`A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP`).
1701
- *
1702
- * 🔑 **Opt-in, because rigc's output is not wrong.** A physics constraint
1703
- * driving `rotate` is valid Spine 4.3 and every runtime plays it: a cowlick, a
1704
- * tail, an ear. What is true is that one consumer discards it — measured, not
1705
- * inferred (issue #540): the editor's physics model holds `x` and `y` only,
1706
- * with no cap on how many at once, and a lone `rotate`, `scaleX` or `shearX`
1707
- * comes back driving nothing at all. Refusing that by default would be rigc
1708
- * refusing correct data on behalf of a pipeline nobody told it about, which is
1709
- * the same silence pointed the other way. rigc knows what the object is; only
1710
- * the rig knows which consumers it is for.
1711
- *
1712
- * ⚠️ **Declaring nothing is not the same as being told nothing.** `A41` SKIPs
1713
- * on a rig that stays quiet — and the SKIP names the constraint and the
1714
- * component a round trip would drop, because the defect this field exists for
1715
- * is that nobody finds out. Declaring `true` turns that sentence into a
1716
- * refusal.
1717
- *
1718
- * Only `true` is accepted. A `false` here would be a key nothing reads (issue
1719
- * #545), and leaving it out says the same thing without the ambiguity.
1720
- */
1721
- editorRoundTrip?: boolean;
1722
1702
  /**
1723
1703
  * Ik and transform constraints whose mix **the consumer sets**, from code,
1724
1704
  * rather than any animation in this file — so `A47` / `A48` do not refuse
@@ -1882,7 +1862,7 @@ export const RIG_KEYS = {
1882
1862
  RigBoneFrom: ['anchor', 'slotWindow', 'meshCenter', 'rotation'],
1883
1863
  RigSlot: ['name', 'bone', 'attachment', 'color', 'dark', 'blend'],
1884
1864
  RigEvent: ['int', 'float', 'string', 'audio', 'volume', 'balance'],
1885
- RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'editorRoundTrip', 'consumerDrivenMix', 'idleDrivesMeshes'],
1865
+ RigInvariants: ['meshSlots', 'meshTriangles', 'axisBone', 'massBone', 'detached', 'deformMayFold', 'consumerDrivenMix', 'idleDrivesMeshes'],
1886
1866
  RigIdleDrivesMeshes: ['why'],
1887
1867
  RigDetachedRule: ['bone', 'notUnder', 'why'],
1888
1868
  RigDeformFoldExemption: ['slot', 'why'],
@@ -1990,7 +1970,7 @@ export const RIG_TYPES = {
1990
1970
  RigEvent: { int: 'number', float: 'number', string: 'string', audio: 'string', volume: 'number', balance: 'number' },
1991
1971
  RigInvariants: {
1992
1972
  meshSlots: 'number', meshTriangles: 'number', axisBone: 'string', massBone: 'string', detached: 'object[]',
1993
- deformMayFold: 'object[]', editorRoundTrip: 'boolean', consumerDrivenMix: 'object[]', idleDrivesMeshes: 'object',
1973
+ deformMayFold: 'object[]', consumerDrivenMix: 'object[]', idleDrivesMeshes: 'object',
1994
1974
  },
1995
1975
  RigIdleDrivesMeshes: { why: 'string' },
1996
1976
  RigDetachedRule: { bone: 'string', notUnder: 'string', why: 'string' },
@@ -2673,19 +2653,6 @@ export function parseRigSpec(raw: unknown, where: string): RigSpec {
2673
2653
  }
2674
2654
  }
2675
2655
 
2676
- // `invariants.editorRoundTrip` — the field that turns a check ON, so the only
2677
- // thing it can be wrong about is saying nothing while looking like it said
2678
- // something. `false` is refused for exactly that: it reads as a decision and
2679
- // behaves as an absence, which is the shape issue #545 closed elsewhere.
2680
- const roundTrip = spec.invariants?.editorRoundTrip;
2681
- if (roundTrip !== undefined && roundTrip !== true) {
2682
- throw new CompileError(
2683
- `${where}: invariants.editorRoundTrip is ${JSON.stringify(roundTrip)}; the only accepted value is \`true\`. ` +
2684
- 'A rig that is not authored for the editor leaves the key out — A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP then ' +
2685
- 'SKIPs and still names anything a round trip would drop, so nothing is lost by saying nothing',
2686
- );
2687
- }
2688
-
2689
2656
  // A constraint's namespace is its KIND, not the array (issue #692).
2690
2657
  // `SkeletonData.findConstraint(name, type)` tests `constraint instanceof type`
2691
2658
  // BEFORE it compares the name, and every resolution in the format goes through
package/src/types.ts CHANGED
@@ -1478,13 +1478,6 @@ export interface RigInfo {
1478
1478
  * is on.
1479
1479
  */
1480
1480
  deformMayFold: string[];
1481
- /**
1482
- * The rig declared `invariants.editorRoundTrip: true` — it is authored to come
1483
- * back out of the Spine editor, so `A41` gates what that consumer cannot hold.
1484
- * False is the ordinary case and is not a weaker gate: A41 then SKIPs, and the
1485
- * SKIP still names anything a round trip would drop.
1486
- */
1487
- editorRoundTrip: boolean;
1488
1481
  /**
1489
1482
  * Ik and transform constraints whose mix the consumer sets, from
1490
1483
  * `invariants.consumerDrivenMix`, in the rig spec's order. `A47` / `A48` do
package/src/validate.ts CHANGED
@@ -120,7 +120,6 @@ import { a28RibbonRowsShareWeights } from './assertions/bodies/a28.ts';
120
120
  import { a33VertexAttachmentGeometry } from './assertions/bodies/a33.ts';
121
121
  import { a36PathConstraintEffective } from './assertions/bodies/a36.ts';
122
122
  import { a37SliderConstraintEffective } from './assertions/bodies/a37.ts';
123
- import { a41PhysicsSurvivesEditorRoundTrip } from './assertions/bodies/a41.ts';
124
123
  import { a42DrivenConstraintsUpdateAfterTheirDriver } from './assertions/bodies/a42.ts';
125
124
  import { a44LinkedMeshStatesNoGeometryOfItsOwn } from './assertions/bodies/a44.ts';
126
125
  import { a47IkConstraintNotMutedThroughout } from './assertions/bodies/a47.ts';
@@ -1615,6 +1614,7 @@ export function spineConstraintFacts(data: ReturnType<SkeletonJson['readSkeleton
1615
1614
  runtimeClass,
1616
1615
  physics: {
1617
1616
  bone: c.bone.name,
1617
+ boneLength: c.bone.length,
1618
1618
  components: { x: c.x, y: c.y, rotate: c.rotate, scaleX: c.scaleX, shearX: c.shearX },
1619
1619
  setup: { mix: pose.mix, massInverse: pose.massInverse, strength: pose.strength, damping: pose.damping },
1620
1620
  step: c.step,
@@ -2798,7 +2798,7 @@ export function validate(input: ValidateInput): ValidateReport {
2798
2798
  // survey the two hand it held to one by `tools/survey_hashes.ts`.
2799
2799
  check('A39_DEFORM_KEEPS_TRIANGLE_WINDING', () => a39DeformKeepsTriangleWinding(verdicts, spineDeformSurvey(data), input.rig));
2800
2800
 
2801
- // --- A23, A41, A36, A37, A47, A48: a constraint that does nothing, quietly -
2801
+ // --- A23, A36, A37, A47, A48: a constraint that does nothing, quietly -
2802
2802
  //
2803
2803
  // Their bodies, the reasoning each states and the one reading of "does an
2804
2804
  // animation switch this on" they share (`switchedOn`, which replaced the
@@ -2808,13 +2808,6 @@ export function validate(input: ValidateInput): ValidateReport {
2808
2808
  // `ConstraintFacts`, which `spineConstraintFacts` supplies here.
2809
2809
  check('A23_PHYSICS_CONSTRAINT_EFFECTIVE', () => a23PhysicsConstraintEffective(verdicts, constraintFacts(), loadedMeshFacts()));
2810
2810
 
2811
- // A41 — `validity` rather than policy, on A09's precedent: what it measures
2812
- // is the artifact against a claim the artifact's own spec makes, and a rig
2813
- // that makes no such claim has nothing to be measured against and SKIPs.
2814
- // Nothing here is one renderer's taste or one formation's shape, so there is
2815
- // no profile it should be hidden behind.
2816
- check('A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP', () => a41PhysicsSurvivesEditorRoundTrip(verdicts, constraintFacts(), input.rig));
2817
-
2818
2811
  check('A36_PATH_CONSTRAINT_EFFECTIVE', () => a36PathConstraintEffective(verdicts, constraintFacts()));
2819
2812
 
2820
2813
  check('A37_SLIDER_CONSTRAINT_EFFECTIVE', () => a37SliderConstraintEffective(verdicts, constraintFacts()));
@@ -1,83 +0,0 @@
1
- /**
2
- * A41, the body (issue #1025, step 4c of #380): a physics component the Spine
3
- * editor cannot hold, refused on a rig declared for the editor.
4
- *
5
- * 🚨 The silence this converts is not in the artifact — it is one consumer
6
- * downstream of it. An author builds a rig whose cowlick jiggles, opens it
7
- * in the editor to move an eyebrow, saves, exports, and the hair has stopped
8
- * moving. The returned file says nothing: the component is simply absent,
9
- * and absent parses as 0. `gallery/look` is exactly that rig.
10
- *
11
- * 🔑 **rigc's output is correct, so the refusal is opt-in.** `rotate` on a
12
- * physics constraint is valid Spine 4.3 that every runtime plays, and
13
- * refusing it by default would be refusing correct data on behalf of a
14
- * pipeline rigc was never told about. What rigc can see is the object; which
15
- * consumers it is for is the rig's to say, and it says it with
16
- * `invariants.editorRoundTrip` (`src/rig.ts`).
17
- *
18
- * ⚠️ **The SKIP is the other half of the product and is not a shrug.** A rig
19
- * that declares nothing is not gated — but the reason names the constraint
20
- * and the components a round trip would drop, so the one thing that must not
21
- * happen (nobody finds out) does not happen either. That is why this reads
22
- * the whole skeleton before it reads the declaration, rather than returning
23
- * early on a rig that asked for nothing.
24
- *
25
- * 🔸 **Against A23, on the far side of the same trip.** A23 refuses a
26
- * constraint driving NOTHING — which is what the editor hands back, after
27
- * the loss — and it is what fires today on a round-tripped `look`. This
28
- * refuses a constraint driving something the editor will not keep, before
29
- * the trip. They cannot both fire on one constraint: A23's condition is an
30
- * empty driven set and this one's is a non-empty one (see
31
- * `PHYSICS_COMPONENTS`), so the two are disjoint by construction rather than
32
- * by agreement.
33
- *
34
- * Moved out of `src/validate.ts` whole: the components are the document's
35
- * physics records, the declaration the rig's.
36
- */
37
- import type { Verdicts } from '../harness.ts';
38
- import type { ConstraintFacts } from '../facts/constraints.ts';
39
- import { EDITOR_PHYSICS_COMPONENTS, PHYSICS_COMPONENTS } from '../constraint_words.ts';
40
- import type { RigInfo } from '../../types.ts';
41
-
42
- export function a41PhysicsSurvivesEditorRoundTrip({ fail, skip }: Verdicts, facts: ConstraintFacts, rig: RigInfo | undefined): void {
43
- // Counted here rather than read off `stats.physicsConstraints`: that entry
44
- // is written by A23, and a rule whose SKIP depends on another rule having
45
- // run is a rule that reports "nothing to measure" when its neighbour threw.
46
- const physics = facts.constraints.flatMap((c) => (c.physics === undefined ? [] : [{ name: c.name, components: c.physics.components }]));
47
- const dropped: string[] = [];
48
- for (const constraint of physics) {
49
- const lost = PHYSICS_COMPONENTS.filter((k) => constraint.components[k] > 0 && !EDITOR_PHYSICS_COMPONENTS.has(k));
50
- if (lost.length) dropped.push(`physics "${constraint.name}" drives ${lost.join(', ')}`);
51
- }
52
- const editorKeeps = [...EDITOR_PHYSICS_COMPONENTS].join(' and ');
53
- const found =
54
- dropped.length === 0
55
- ? 'no physics constraint here drives a component it would discard'
56
- : `${dropped.join('; ')}, and the editor's physics model holds ${editorKeeps} only, so a round trip ` +
57
- 'returns that constraint driving nothing at all (issue #540)';
58
- if (rig?.editorRoundTrip !== true) {
59
- return skip(
60
- 'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
61
- `${
62
- rig
63
- ? `the rig "${rig.archetype}" does not declare \`invariants.editorRoundTrip\``
64
- : 'this is a bare directory, with no rig info to declare `invariants.editorRoundTrip`'
65
- }, so nothing here is gated against the Spine editor. What is here: ${found}`,
66
- );
67
- }
68
- if (physics.length === 0) {
69
- return skip(
70
- 'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
71
- `the rig "${rig.archetype}" is declared for the editor, but it carries no physics constraint — ` +
72
- 'there is nothing here whose components could be lost',
73
- );
74
- }
75
- for (const one of dropped) {
76
- fail(
77
- 'A41_PHYSICS_SURVIVES_EDITOR_ROUND_TRIP',
78
- `${one}; the editor's physics model holds ${editorKeeps} only, and this ` +
79
- 'rig declares `invariants.editorRoundTrip` — drive it in x/y, or drop the declaration if this rig never ' +
80
- 'goes through the editor (issue #540)',
81
- );
82
- }
83
- }