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 +2 -2
- package/README.md +12 -7
- package/docs/AUTHORING.md +61 -5
- package/package.json +1 -1
- package/src/assertions/bodies/a52.ts +146 -0
- package/src/assertions/emitted/index.ts +7 -4
- package/src/assertions/kinds.ts +1 -0
- package/src/cli/shared.ts +1 -1
- package/src/compile.ts +141 -0
- package/src/core/constraints_physics.ts +106 -6
- package/src/core/entry.ts +4 -2
- package/src/core/raw.ts +48 -1
- package/src/core/track.ts +65 -1
- package/src/core/tracks.ts +29 -1
- package/src/validate.ts +6 -0
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
|
|
44
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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,
|
|
13
|
-
* curve arrays (A31, A35, A16, A01, A02, A05), and A18's
|
|
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) },
|
package/src/assertions/kinds.ts
CHANGED
|
@@ -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 += (
|
|
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 -= (
|
|
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
|
|
724
|
-
const
|
|
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 += (
|
|
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 -= ((
|
|
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`);
|
package/src/core/tracks.ts
CHANGED
|
@@ -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
|