spine-rigc 2.13.3 → 2.14.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +2 -2
- package/README.md +6 -6
- package/docs/AUTHORING.md +15 -35
- package/docs/FACE.md +31 -20
- package/package.json +1 -1
- package/src/assertions/bodies/a23.ts +50 -0
- package/src/assertions/constraint_words.ts +12 -20
- package/src/assertions/facts/constraints.ts +15 -3
- package/src/assertions/kinds.ts +0 -1
- package/src/assertions/model/constraints.ts +2 -0
- package/src/assertions/model/declared.ts +0 -2
- package/src/assertions/model/index.ts +0 -2
- package/src/assertions/model/stepped_poses.ts +16 -7
- package/src/compile.ts +7 -8
- package/src/core/animation.ts +11 -0
- package/src/core/raw.ts +216 -17
- package/src/core/walk.ts +21 -1
- package/src/rig.ts +8 -41
- package/src/types.ts +0 -7
- package/src/validate.ts +2 -9
- package/src/assertions/bodies/a41.ts +0 -83
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
|
|
42
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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`,
|
|
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
|
|
6369
|
-
and the core report's last line states the split —
|
|
6370
|
-
which are the
|
|
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
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
`
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
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.
|
|
3
|
+
"version": "2.14.1",
|
|
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`),
|
|
18
|
-
*
|
|
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
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
-
*
|
|
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?: {
|
|
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,
|
|
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;
|
package/src/assertions/kinds.ts
CHANGED
|
@@ -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 },
|
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
* rather than refusing it. It is held equal to spine-core's walk at
|
|
14
14
|
* tolerance 0 by `tools/core_gate.ts`'s walk block and the selftest's
|
|
15
15
|
* `CO25`, and the poses A10 asks for here are compared with spine-core's on
|
|
16
|
-
* every call with a model in hand (`VF13`).
|
|
16
|
+
* every call with a model in hand (`VF13`). Since issue #1179's second part
|
|
17
|
+
* the poses come through that walk's scan assembler (`scanWalkSetup`,
|
|
18
|
+
* `scanLoopingWalk`): the same walk, each pose carrying what A10 reads — the
|
|
19
|
+
* bones' matrix and origin, the slot rows, the drawn attachments' slot, name
|
|
20
|
+
* and vertices — and none of the bones' getter readings and oracle rows or
|
|
21
|
+
* the drawn rows' UVs, triangles and colour, which nothing it reads is
|
|
22
|
+
* computed from. The core suite's `CO43` holds the scan to the public walk's
|
|
23
|
+
* numbers bit for bit, `CO44` what it leaves out to move none of them, and
|
|
24
|
+
* `CO45` that the vertex arrays it hands on uncopied are each pose's own.
|
|
17
25
|
* - **A bone's mode**: the mode the core's bone timelines pose at the time
|
|
18
26
|
* (`posedBones`, a fresh non-looping track: the time held at the duration),
|
|
19
27
|
* folded as the runtime's lookup folds it (`foldInheritMode`). `readModel`
|
|
@@ -30,7 +38,8 @@
|
|
|
30
38
|
import { fileAnimationOrder } from '../../compile.ts';
|
|
31
39
|
import { foldInheritMode, type CompiledDocument } from '../../core/index.ts';
|
|
32
40
|
import { posedBones } from '../../core/animation.ts';
|
|
33
|
-
import {
|
|
41
|
+
import { scanLoopingWalk, scanWalkSetup } from '../../core/walk.ts';
|
|
42
|
+
import type { ScanPose } from '../../core/raw.ts';
|
|
34
43
|
import { noSkinView } from '../../render_core.ts';
|
|
35
44
|
import type { ModelBone } from '../../model.ts';
|
|
36
45
|
import type { SteppedFrame, SteppedPoseFacts } from '../facts/stepped_poses.ts';
|
|
@@ -45,11 +54,11 @@ const channel = (v: number | null): number => (v === null ? Number.NaN : v);
|
|
|
45
54
|
/** Each bone's posed mode as the facts spell it (`modeOf`), by name. */
|
|
46
55
|
const modesOf = (posed: readonly ModelBone[]): ReadonlyMap<string, string | null> => new Map(posed.map((b) => [b.name, modeOf(b)]));
|
|
47
56
|
|
|
48
|
-
/** One
|
|
49
|
-
function frameOf(pose:
|
|
57
|
+
/** One scan pose as A10 reads it, each bone's mode taken from `modes` (`modesOf` the bones the timelines posed at the pose's time); the drawn attachments are the scan's own rows, already in the shape A10 reads. */
|
|
58
|
+
function frameOf(pose: ScanPose, modes: ReadonlyMap<string, string | null>): SteppedFrame {
|
|
50
59
|
return {
|
|
51
60
|
bones: pose.bones.map((b) => ({ name: b.name, a: b.a, b: b.b, c: b.c, d: b.d, worldX: b.worldX, worldY: b.worldY, inherit: modes.get(b.name) ?? null })),
|
|
52
|
-
drawn: pose.drawn
|
|
61
|
+
drawn: pose.drawn,
|
|
53
62
|
slots: pose.slots.map((row) => ({
|
|
54
63
|
name: row[0],
|
|
55
64
|
colour: [channel(row[2]), channel(row[3]), channel(row[4]), channel(row[5])] as const,
|
|
@@ -82,10 +91,10 @@ export function modelSteppedPoses(read: ReadDocument): SteppedPoseFacts {
|
|
|
82
91
|
},
|
|
83
92
|
// The document's stated mode — asked only of a bone posing none, which the reader leaves this side no document to hold.
|
|
84
93
|
statedInherit: (name) => `${JSON.stringify(read.doc.bones.find((b) => b.name === name)?.inheritMode)}`,
|
|
85
|
-
setup: () => frameOf(
|
|
94
|
+
setup: () => frameOf(scanWalkSetup(viewOf()), modesOf(viewOf().bones)),
|
|
86
95
|
walk: (animation, step, frames) => {
|
|
87
96
|
const anim = animationOf(animation);
|
|
88
|
-
const poses =
|
|
97
|
+
const poses = scanLoopingWalk(viewOf(), animation, new Array<number>(frames).fill(step));
|
|
89
98
|
// A bone's posed mode moves only by an `inherit` timeline: every other one `posedBones` reads leaves the setup's mode on its copy. So an animation
|
|
90
99
|
// keying none poses the setup's modes at every time, and they are read once rather than posed again at each of its frames (issue #1179).
|
|
91
100
|
const keysInherit = anim.timelines.bones.some((target) => target.timelines.some((tl) => tl.kind === 'inherit'));
|
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
|
|
841
|
-
*
|
|
842
|
-
*
|
|
843
|
-
*
|
|
844
|
-
*
|
|
845
|
-
*
|
|
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/core/animation.ts
CHANGED
|
@@ -797,6 +797,17 @@ export function posedBoneWorld(doc: CompiledDocument, timelines: CoreAnimationTi
|
|
|
797
797
|
return { rows: stepRows(posed), world: posed.world };
|
|
798
798
|
}
|
|
799
799
|
|
|
800
|
+
/**
|
|
801
|
+
* `posedBoneWorld`'s world transforms without its rows (issue #1179, the
|
|
802
|
+
* second part): the same pose by the same operations in the same order, since
|
|
803
|
+
* `stepRows` only reads the world once it is posed. A walk reads `world` and
|
|
804
|
+
* no row, so the scan's walk (`scanWalkIn` in `./raw.ts`, A10's) asks for
|
|
805
|
+
* this; the raw entry's walk still forms the rows it does not read, unchanged.
|
|
806
|
+
*/
|
|
807
|
+
export function posedBoneWorldAlone(doc: CompiledDocument, timelines: CoreAnimationTimelines, t: number, plant: TimelinePlant, constraints: CoreConstraintTimelines | undefined, sliders: SliderApplication[] | undefined, step: { ctx: PhysicsStepContext; before: number } | undefined): Map<string, CoreWorld> {
|
|
808
|
+
return posedBoneStep(doc, timelines, t, plant, constraints, sliders, step).world;
|
|
809
|
+
}
|
|
810
|
+
|
|
800
811
|
/**
|
|
801
812
|
* One pose of the bones before its rows are read: the world transforms, and
|
|
802
813
|
* what the rows are rounded from. A stepped walk reads rows only off the step
|
package/src/core/raw.ts
CHANGED
|
@@ -114,14 +114,14 @@
|
|
|
114
114
|
* missing would draw a different picture in silence.
|
|
115
115
|
*/
|
|
116
116
|
import { activeBones, CoreInputError, drawWalkOf, poseSetup, rawNumber, readColour, shownAttachment, sourceOfDoc, underNoSkin, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
|
|
117
|
-
import { posedBoneWorld, posedSlots, type TimelinePlant } from './animation.ts';
|
|
117
|
+
import { posedBoneWorld, posedBoneWorldAlone, posedSlots, type TimelinePlant } from './animation.ts';
|
|
118
118
|
import { constraintsAbsentWhy, pathAnimationsWhy } from './constraints.ts';
|
|
119
119
|
import { freshStepContext, steppedPreviousPassWhy } from './constraints_physics.ts';
|
|
120
120
|
import { attachmentStates } from './deform.ts';
|
|
121
121
|
import { drawOrderAt } from './draw_order.ts';
|
|
122
122
|
import { eventsFired } from './events.ts';
|
|
123
|
-
import { REGION_TRIANGLES, REGION_UVS, type ShapeClipper } from './clipping.ts';
|
|
124
|
-
import { poseGeometry, type ShownGeometry } from './vertices.ts';
|
|
123
|
+
import { REGION_TRIANGLES, REGION_UVS, type CoreClippedRow, type ShapeClipper } from './clipping.ts';
|
|
124
|
+
import { poseGeometry, type CoreAttachmentRow, type CoreClipRow, type ShownGeometry } from './vertices.ts';
|
|
125
125
|
import { RUNTIME_DEG, type CoreWorld } from './world.ts';
|
|
126
126
|
import type { SliderApplication } from './constraints_slider.ts';
|
|
127
127
|
|
|
@@ -195,6 +195,43 @@ export interface RawPose {
|
|
|
195
195
|
// --- #968 render: end ---
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* One bone as the scan reads it (issue #1179, the second part): the world
|
|
200
|
+
* matrix and origin `RawBone` carries, and none of the readings it forms from
|
|
201
|
+
* them. `A10_NO_NAN_AFTER_STEPPING` reads these six numbers and the name off a
|
|
202
|
+
* bone, and nothing else (`firstNonFinite` in `src/nonfinite.ts`).
|
|
203
|
+
*/
|
|
204
|
+
export interface ScanBone {
|
|
205
|
+
name: string;
|
|
206
|
+
a: number;
|
|
207
|
+
b: number;
|
|
208
|
+
c: number;
|
|
209
|
+
d: number;
|
|
210
|
+
worldX: number;
|
|
211
|
+
worldY: number;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* One pose as the scan reads it (issue #1179, the second part) — the scan's
|
|
216
|
+
* walk (`scanWalkIn`, `scanSetupIn`): the time the animation was applied at,
|
|
217
|
+
* and the bones, slot rows and drawn attachments computed exactly as a
|
|
218
|
+
* `RawPose`'s are. Internal to A10's walk (`./walk.ts`); no exported pose type
|
|
219
|
+
* changed for it.
|
|
220
|
+
*/
|
|
221
|
+
export interface ScanPose {
|
|
222
|
+
animationTime: number;
|
|
223
|
+
bones: ScanBone[];
|
|
224
|
+
slots: CoreSlotRow[];
|
|
225
|
+
drawn: ScanDrawn[];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** One region or mesh drawn, as the scan reads it: its slot, its name and its world vertices, as `RawDrawn` carries them. */
|
|
229
|
+
export interface ScanDrawn {
|
|
230
|
+
slot: string;
|
|
231
|
+
attachment: string;
|
|
232
|
+
vertices: number[];
|
|
233
|
+
}
|
|
234
|
+
|
|
198
235
|
// --- #968 render: begin ---
|
|
199
236
|
/** The shown records in `order`, the pose's draw order (a pose's `shown`). */
|
|
200
237
|
function drawOrderOf(shown: readonly ShownGeometry[], order: readonly string[]): ShownGeometry[] {
|
|
@@ -274,17 +311,96 @@ function rawBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>):
|
|
|
274
311
|
});
|
|
275
312
|
}
|
|
276
313
|
|
|
277
|
-
/**
|
|
278
|
-
|
|
314
|
+
/**
|
|
315
|
+
* The bones of a scan pose (`ScanBone`): `rawBones` less the parent, the
|
|
316
|
+
* active flag and the four getter readings, which nothing the scan reads is
|
|
317
|
+
* computed from — each reading is formed from the world after it is posed,
|
|
318
|
+
* and the matrix and origin are copied as they are.
|
|
319
|
+
*/
|
|
320
|
+
function scanBones(doc: CompiledDocument, world: ReadonlyMap<string, CoreWorld>): ScanBone[] {
|
|
321
|
+
return doc.bones.map((b): ScanBone => {
|
|
322
|
+
const w = world.get(b.name);
|
|
323
|
+
if (w === undefined) throw new CoreInputError(`bone "${b.name}" has no world transform`);
|
|
324
|
+
return { name: b.name, a: w.a, b: w.b, c: w.c, d: w.d, worldX: w.worldX, worldY: w.worldY };
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* What a pose draws, posed and refused where the core leaves it out: the
|
|
330
|
+
* attachment rows, the clip rows and the drawn clipped rows — what `rawDrawn`
|
|
331
|
+
* and `scanDrawn` both assemble from.
|
|
332
|
+
*/
|
|
333
|
+
function drawnGeometry(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode): { rows: CoreAttachmentRow[]; clips: CoreClipRow[]; drawnClipped: CoreClippedRow[] } {
|
|
279
334
|
const draw = drawWalkOf(doc, order, plant);
|
|
280
335
|
const geometry = poseGeometry(shown, world, sourceOfDoc(doc), mode.keep ? keptNumber : rawNumber, { region: plant.region, vertices: plant.vertices }, mode.loop && plant.through === undefined ? { ...draw, through: NO_CLIPPED_ROWS } : draw);
|
|
281
336
|
if (geometry.attachments === null) throw new CoreInputError(`the raw pose leaves the attachments out: ${geometry.attachmentsWhy}`);
|
|
282
337
|
// The drawn rows (issue #964): the oracle's `clipped` rows where it is posed, and a concave or inverse clip cut through the core's own decomposition.
|
|
283
338
|
if (geometry.drawnClipped === null) throw new CoreInputError(`the raw pose leaves the clipped triangles out: ${geometry.drawnClippedWhy}`);
|
|
339
|
+
return { rows: geometry.attachments, clips: geometry.clips, drawnClipped: geometry.drawnClipped };
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The drawn attachments of a scan pose (`ScanDrawn`): `rawDrawn`'s rows, each
|
|
344
|
+
* its slot, its name and its world vertices through the same `numberOf`, and
|
|
345
|
+
* none of what `rawDrawn` reads past them for a renderer — the UVs and
|
|
346
|
+
* triangles it copies, the record it looks up for the colour and the sequence
|
|
347
|
+
* frame, the hull, the clip rows' numbers. No vertex is computed from any of
|
|
348
|
+
* those. The one refusal `rawDrawn` makes past the rows, a linked mesh whose
|
|
349
|
+
* source carries no mesh, is not reachable here: `poseGeometry` resolved the
|
|
350
|
+
* same source by the same lookup (`sourceOfDoc`) and throws before it returns.
|
|
351
|
+
*
|
|
352
|
+
* 🔁 **No copy of the vertices** (issue #1179, the second part): `rawDrawn`
|
|
353
|
+
* maps each row through `numberOf` into a new array; under `keep` that map is
|
|
354
|
+
* the identity on every number and turns only a `null` into NaN. The scan
|
|
355
|
+
* hands the row's own array on wherever it holds no `null` (`allNumbers`) and
|
|
356
|
+
* maps it as before where it does, so A10 reads the same values either way.
|
|
357
|
+
* What makes the row's array safe to keep past the step — A10 collects an
|
|
358
|
+
* animation's 120 frames before it scans one — is that it is the pose's own:
|
|
359
|
+
* `poseGeometry` (`./vertices.ts`) builds every row's vertices with
|
|
360
|
+
* `.map(round)` over the corners or the skinned vertices it just computed, a
|
|
361
|
+
* new array per attachment per pose, held by nothing but the row. The core
|
|
362
|
+
* suite's `CO45` plants a poser that hands every pose the same array and reads
|
|
363
|
+
* the retained frames red.
|
|
364
|
+
*/
|
|
365
|
+
function scanDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode, scanPlant: ScanPlant = {}): ScanDrawn[] {
|
|
366
|
+
const { rows } = drawnGeometry(doc, shown, world, order, plant, mode);
|
|
367
|
+
const drawn: ScanDrawn[] = [];
|
|
368
|
+
let k = 0;
|
|
369
|
+
for (const s of shown) {
|
|
370
|
+
const g = s.geometry;
|
|
371
|
+
if (g.kind !== 'region' && g.kind !== 'mesh' && g.kind !== 'linkedmesh') continue;
|
|
372
|
+
const row = rows[k++];
|
|
373
|
+
if (row === undefined || row[0] !== s.slot) throw new CoreInputError(`slot "${s.slot}": the attachment rows are not in the shown records' order`);
|
|
374
|
+
const vertices = row[3];
|
|
375
|
+
const kept = mode.keep && allNumbers(vertices) ? vertices : vertices.map((v) => numberOf(v, `vertex of slot "${s.slot}"`, mode));
|
|
376
|
+
drawn.push({ slot: s.slot, attachment: row[1], vertices: scanPlant.vertices === undefined ? kept : scanPlant.vertices(kept, s.slot) });
|
|
377
|
+
}
|
|
378
|
+
return drawn;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Whether a row's vertices hold no `null` — what `numberOf` would map — so the row's own array can be handed on as numbers. */
|
|
382
|
+
function allNumbers(values: Array<number | null>): values is number[] {
|
|
383
|
+
for (const v of values) if (v === null) return false;
|
|
384
|
+
return true;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The scan's one plant (the core suite's `CO45`): what a drawn row's vertices
|
|
389
|
+
* are handed on as, given the array the scan would hand on. A poser that
|
|
390
|
+
* reuses one buffer per slot across steps is the plant `CO45` passes.
|
|
391
|
+
* Nothing but a control passes one.
|
|
392
|
+
*/
|
|
393
|
+
export interface ScanPlant {
|
|
394
|
+
vertices?: (vertices: number[], slot: string) => number[];
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/** Every region and mesh the shown records draw, with what the renderer reads past the vertices (the header's `drawn`). */
|
|
398
|
+
function rawDrawn(doc: CompiledDocument, shown: readonly ShownGeometry[], world: ReadonlyMap<string, CoreWorld>, order: readonly string[], plant: CorePlant, mode: WalkMode = RAW_MODE): { drawn: RawDrawn[]; clips: RawClip[]; clipped: RawClipped[] } {
|
|
399
|
+
const geometry = drawnGeometry(doc, shown, world, order, plant, mode);
|
|
284
400
|
// Under a concave or inverse clip the oracle's `clipped` block is absent (the runtime's own triangle list); what the core draws is `drawnClipped`,
|
|
285
401
|
// its own decomposition, and the render samples each drawn triangle at its source triangle's affine UV, so the pixels are the decomposition's
|
|
286
402
|
// coverage alone (issue #964, `Mesh.source` in src/render.ts).
|
|
287
|
-
const rows = geometry.
|
|
403
|
+
const rows = geometry.rows;
|
|
288
404
|
const drawn: RawDrawn[] = [];
|
|
289
405
|
let k = 0;
|
|
290
406
|
for (const s of shown) {
|
|
@@ -326,14 +442,27 @@ export function poseRawSetup(doc: CompiledDocument, plant: CorePlant = {}): RawP
|
|
|
326
442
|
|
|
327
443
|
/** `poseRawSetup` in `mode` — under `keep`, a value that is not finite stays in the pose (`./walk.ts`). */
|
|
328
444
|
export function setupPoseIn(doc: CompiledDocument, plant: CorePlant, mode: WalkMode): RawPose {
|
|
445
|
+
const { world, shown, slots, drawOrder } = setupPosed(doc, plant, mode);
|
|
446
|
+
const drawn = rawDrawn(doc, shown, world, drawOrder, plant, mode);
|
|
447
|
+
return { trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots, drawOrder, ...drawn, events: [], shown: drawOrderOf(shown, drawOrder) };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** `setupPoseIn` as the scan reads it (`ScanPose`): the same setup pose, the same refusals in the same order, assembled into the scan's pose. */
|
|
451
|
+
export function scanSetupIn(doc: CompiledDocument, plant: CorePlant, mode: WalkMode, scanPlant: ScanPlant = {}): ScanPose {
|
|
452
|
+
const { world, shown, slots, drawOrder } = setupPosed(doc, plant, mode);
|
|
453
|
+
const drawn = scanDrawn(doc, shown, world, drawOrder, plant, mode, scanPlant);
|
|
454
|
+
return { animationTime: 0, bones: scanBones(doc, world), slots, drawn };
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The setup pose `setupPoseIn` and `scanSetupIn` assemble, refused by name where the core leaves a block of it out. */
|
|
458
|
+
function setupPosed(doc: CompiledDocument, plant: CorePlant, mode: WalkMode): { world: Map<string, CoreWorld>; shown: ShownGeometry[]; slots: CoreSlotRow[]; drawOrder: string[] } {
|
|
329
459
|
const posed = poseSetup(doc, { ...plant, ...roundPlant(mode) }, freshStepContext(plant.physicsStep));
|
|
330
460
|
// `setup.clipped` is the oracle's block, left out under a concave or inverse clip whose triangle list is the runtime's own; what the core draws there is `rawDrawn`'s to refuse or pose (issue #964).
|
|
331
461
|
const absent = posed.absent.filter(([block]) => block !== 'setup.clipped');
|
|
332
462
|
if (absent.length > 0) throw new CoreInputError(`the raw setup pose leaves ${absent.map(([b, why]) => `${b} out (${why})`).join('; ')}`);
|
|
333
463
|
const { setup, world, shown } = posed;
|
|
334
464
|
if (world === null || shown === null || setup.slots === null || setup.drawOrder === null) throw new CoreInputError('the raw setup pose was not posed');
|
|
335
|
-
|
|
336
|
-
return { trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots: setup.slots, drawOrder: setup.drawOrder, ...drawn, events: [], shown: drawOrderOf(shown, setup.drawOrder) };
|
|
465
|
+
return { world, shown, slots: setup.slots, drawOrder: setup.drawOrder };
|
|
337
466
|
}
|
|
338
467
|
|
|
339
468
|
/**
|
|
@@ -361,7 +490,7 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
|
|
|
361
490
|
* poses before it.
|
|
362
491
|
*/
|
|
363
492
|
export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void): void {
|
|
364
|
-
walkEach(doc, animation, steps, plant, reset, RAW_MODE, visit);
|
|
493
|
+
walkEach(doc, animation, steps, plant, reset, RAW_MODE, RAW_SHAPE, visit);
|
|
365
494
|
}
|
|
366
495
|
|
|
367
496
|
/**
|
|
@@ -381,12 +510,81 @@ export function loopedTime(trackTime: number, duration: number): number {
|
|
|
381
510
|
*/
|
|
382
511
|
export function walkIn(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode): RawPose[] {
|
|
383
512
|
const poses: RawPose[] = [];
|
|
384
|
-
walkEach(doc, animation, steps, plant, reset, mode, (pose) => poses.push(pose));
|
|
513
|
+
walkEach(doc, animation, steps, plant, reset, mode, RAW_SHAPE, (pose) => poses.push(pose));
|
|
385
514
|
return poses;
|
|
386
515
|
}
|
|
387
516
|
|
|
388
|
-
/**
|
|
389
|
-
|
|
517
|
+
/**
|
|
518
|
+
* `walkIn` as the scan reads it (`ScanPose`, issue #1179, the second part):
|
|
519
|
+
* the same walk — every pose posed by the same operations in the same order,
|
|
520
|
+
* every refusal at the same place — each pose assembled into what A10 reads.
|
|
521
|
+
* `./walk.ts`'s `scanLoopingWalk` is its one caller.
|
|
522
|
+
*/
|
|
523
|
+
export function scanWalkIn(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, scanPlant: ScanPlant = {}): ScanPose[] {
|
|
524
|
+
const poses: ScanPose[] = [];
|
|
525
|
+
walkEach(doc, animation, steps, plant, reset, mode, scanPlant.vertices === undefined ? SCAN_SHAPE : scanShape(scanPlant), (pose) => poses.push(pose));
|
|
526
|
+
return poses;
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/** What one step of a walk posed, before a pose is assembled from it (`PoseShape`). */
|
|
530
|
+
interface WalkStep {
|
|
531
|
+
trackTime: number;
|
|
532
|
+
animationTime: number;
|
|
533
|
+
world: Map<string, CoreWorld>;
|
|
534
|
+
slots: CoreSlotRow[];
|
|
535
|
+
drawOrder: string[];
|
|
536
|
+
shown: ShownGeometry[];
|
|
537
|
+
/** The events the step fired, computed when called — after the drawn rows, as the raw entry has always ordered its refusals. */
|
|
538
|
+
events: () => RawEvent[];
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* What a walk assembles each pose into (issue #1179, the second part): the raw
|
|
543
|
+
* entry's whole pose (`RAW_SHAPE`), or the scan's (`SCAN_SHAPE`). The walk
|
|
544
|
+
* (`walkEach`) is one body: every number a pose carries is computed there and
|
|
545
|
+
* in the two assemblers below, by the same calls, whatever the shape. A shape
|
|
546
|
+
* chooses only what is assembled from what was posed — the scan drops the
|
|
547
|
+
* readings, rows and copies nothing it reads is computed from — and keeps the
|
|
548
|
+
* order of every call that can refuse.
|
|
549
|
+
*/
|
|
550
|
+
interface PoseShape<P> {
|
|
551
|
+
/** Whether a step forms the oracle's bone rows beside its world (`posedBoneWorld`), which no walk pose carries; `false` poses the world alone (`posedBoneWorldAlone`). */
|
|
552
|
+
rows: boolean;
|
|
553
|
+
/** The reset pose at the setup (`reset: 'setup'`). */
|
|
554
|
+
setup: (doc: CompiledDocument, world: Map<string, CoreWorld>, slots: CoreSlotRow[], drawOrder: string[], shown: ShownGeometry[], plant: CorePlant, mode: WalkMode) => P;
|
|
555
|
+
/** One step's pose. */
|
|
556
|
+
step: (doc: CompiledDocument, posed: WalkStep, plant: CorePlant, mode: WalkMode) => P;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
/** The raw entry's pose, assembled as it always was: at the reset the bones, then the drawn rows; at a step the drawn rows, the events, then the bones. */
|
|
560
|
+
const RAW_SHAPE: PoseShape<RawPose> = {
|
|
561
|
+
rows: true,
|
|
562
|
+
setup: (doc, world, slots, drawOrder, shown, plant, mode) => ({ trackTime: 0, animationTime: 0, bones: rawBones(doc, world), slots, drawOrder, ...rawDrawn(doc, shown, world, drawOrder, plant, mode), events: [], shown: drawOrderOf(shown, drawOrder) }),
|
|
563
|
+
step: (doc, p, plant, mode) => {
|
|
564
|
+
const drawn = rawDrawn(doc, p.shown, p.world, p.drawOrder, plant, mode);
|
|
565
|
+
const events = p.events();
|
|
566
|
+
return { trackTime: p.trackTime, animationTime: p.animationTime, bones: rawBones(doc, p.world), slots: p.slots, drawOrder: p.drawOrder, ...drawn, events, shown: p.shown };
|
|
567
|
+
},
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/** The scan's pose (`ScanPose`), in the raw shape's order: the scan's walk is a looping one, which fires no event. */
|
|
571
|
+
function scanShape(scanPlant: ScanPlant): PoseShape<ScanPose> {
|
|
572
|
+
return {
|
|
573
|
+
rows: false,
|
|
574
|
+
setup: (doc, world, slots, drawOrder, shown, plant, mode) => {
|
|
575
|
+
const bones = scanBones(doc, world);
|
|
576
|
+
return { animationTime: 0, bones, slots, drawn: scanDrawn(doc, shown, world, drawOrder, plant, mode, scanPlant) };
|
|
577
|
+
},
|
|
578
|
+
step: (doc, p, plant, mode) => {
|
|
579
|
+
const drawn = scanDrawn(doc, p.shown, p.world, p.drawOrder, plant, mode, scanPlant);
|
|
580
|
+
return { animationTime: p.animationTime, bones: scanBones(doc, p.world), slots: p.slots, drawn };
|
|
581
|
+
},
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
const SCAN_SHAPE: PoseShape<ScanPose> = scanShape({});
|
|
585
|
+
|
|
586
|
+
/** `walkIn`'s walk, each pose assembled in `shape` and handed to `visit` in order as it is posed (`poseRawAnimationEach`). */
|
|
587
|
+
function walkEach<P>(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, visit: (pose: P, index: number) => void): void {
|
|
390
588
|
const anim = doc.animations.find((a) => a.name === animation);
|
|
391
589
|
if (anim === undefined) throw new CoreInputError(`animation "${animation}" is not one of this document's [${doc.animations.map((a) => a.name).join(', ')}]`);
|
|
392
590
|
const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
|
|
@@ -408,7 +606,7 @@ function walkEach(doc: CompiledDocument, animation: string, steps: readonly numb
|
|
|
408
606
|
if (absent.length > 0) throw new CoreInputError(`the raw walk's reset pose leaves ${absent.map(([b, w]) => `${b} out (${w})`).join('; ')}`);
|
|
409
607
|
if (setup.world === null || setup.shown === null || setup.setup.slots === null || setup.setup.drawOrder === null) throw new CoreInputError('the raw walk\'s reset pose was not posed');
|
|
410
608
|
ctx.phase = 'update';
|
|
411
|
-
visit(
|
|
609
|
+
visit(shape.setup(doc, setup.world, setup.setup.slots, setup.setup.drawOrder, setup.shown, plant, mode), visited++);
|
|
412
610
|
}
|
|
413
611
|
for (let i = reset === 'setup' ? 1 : 0; i <= steps.length; i++) {
|
|
414
612
|
const dt = i === 0 ? 0 : steps[i - 1];
|
|
@@ -417,7 +615,7 @@ function walkEach(doc: CompiledDocument, animation: string, steps: readonly numb
|
|
|
417
615
|
const before = ctx.time;
|
|
418
616
|
ctx.time += mode.clock === undefined ? dt : mode.clock(dt, t - (i === 0 ? 0 : Math.max(last, 0)));
|
|
419
617
|
const sliders: SliderApplication[] = [];
|
|
420
|
-
const
|
|
618
|
+
const world = shape.rows ? posedBoneWorld(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before }).world : posedBoneWorldAlone(doc, anim.timelines, t, raw, anim.constraints, sliders, { ctx, before });
|
|
421
619
|
if (i === 0) ctx.phase = 'update';
|
|
422
620
|
const placeholders = new Map<string, string | null>();
|
|
423
621
|
const slots = posedSlots(doc, anim.timelines, t, raw, sliders, placeholders);
|
|
@@ -430,12 +628,13 @@ function walkEach(doc: CompiledDocument, animation: string, steps: readonly numb
|
|
|
430
628
|
if (states.why.length > 0) throw new CoreInputError(`the raw walk leaves the attachments out: ${states.why.join('; ')}`);
|
|
431
629
|
const rank = new Map(drawOrder.map((n, r) => [n, r]));
|
|
432
630
|
const shown = [...states.shown].sort((a, b) => (rank.get(a.slot) ?? 0) - (rank.get(b.slot) ?? 0));
|
|
433
|
-
const drawn = rawDrawn(doc, shown, posed.world, drawOrder, plant, mode);
|
|
434
631
|
// A looping walk fires no event here: what fires across the wrap was not measured, and the walk's one reader (A10) reads none. Nor does it cut its
|
|
435
632
|
// drawn attachments through a clip (`NO_CLIPPED_ROWS`), so its `clipped` rows are empty.
|
|
436
|
-
const
|
|
633
|
+
const from = last;
|
|
634
|
+
const events = (): RawEvent[] => (mode.loop ? [] : (plant.events ?? eventsFired)(anim.timelines.events, from, t, rawNumber).map((e): RawEvent => [e[0], finite(e[1], 'event time'), e[2], finite(e[3], 'event float'), e[4]]));
|
|
635
|
+
const pose = shape.step(doc, { trackTime, animationTime: t, world, slots: slots.rows, drawOrder, shown, events }, plant, mode);
|
|
437
636
|
last = t;
|
|
438
|
-
visit(
|
|
637
|
+
visit(pose, visited++);
|
|
439
638
|
}
|
|
440
639
|
}
|
|
441
640
|
|
package/src/core/walk.ts
CHANGED
|
@@ -76,7 +76,7 @@
|
|
|
76
76
|
* poses the class as the raw entry does, and refuses nothing the raw entry
|
|
77
77
|
* does not.
|
|
78
78
|
*/
|
|
79
|
-
import { setupPoseIn, walkIn, type RawBone, type RawDrawn, type WalkMode } from './raw.ts';
|
|
79
|
+
import { scanSetupIn, scanWalkIn, setupPoseIn, walkIn, type RawBone, type RawDrawn, type ScanPlant, type ScanPose, type WalkMode } from './raw.ts';
|
|
80
80
|
import { activeBones, type CompiledDocument, type CorePlant, type CoreSlotRow } from './index.ts';
|
|
81
81
|
import { historyTaint } from './constraints.ts';
|
|
82
82
|
import type { TimelinePlant } from './animation.ts';
|
|
@@ -175,3 +175,23 @@ export function poseLoopingWalk(doc: CompiledDocument, animation: string, steps:
|
|
|
175
175
|
const mode: WalkMode = { ...LOOPING, ...(walkPlant.time === undefined ? {} : { time: walkPlant.time }), ...(walkPlant.clock === undefined ? {} : { clock: walkPlant.clock }), ...(walkPlant.wrapResets === undefined ? {} : { wrapResets: walkPlant.wrapResets }) };
|
|
176
176
|
return walkIn(doc, animation, steps, plant, 'setup', mode).map((p) => narrow(p, walkPlant.number));
|
|
177
177
|
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The scan's walk (issue #1179, the second part): `poseLoopingWalk` and
|
|
181
|
+
* `poseWalkSetup` as A10 reads them, each pose a `ScanPose` — the bones' world
|
|
182
|
+
* matrix and origin, the slot rows and the drawn attachments, and nothing the
|
|
183
|
+
* walk forms beside them for other readers (the bones' oracle rows and getter
|
|
184
|
+
* readings). The same walk: `walkIn`'s body in `./raw.ts`, posing each step by
|
|
185
|
+
* the same operations in the same order and refusing at the same places, with
|
|
186
|
+
* another assembler (`PoseShape`). `src/assertions/model/stepped_poses.ts` is
|
|
187
|
+
* its one caller; the public entries above are unchanged, and the core suite
|
|
188
|
+
* holds the two to the same numbers (`CO43`, `CO44`, `CO45`).
|
|
189
|
+
*/
|
|
190
|
+
export function scanLoopingWalk(doc: CompiledDocument, animation: string, steps: readonly number[], scanPlant: ScanPlant = {}): ScanPose[] {
|
|
191
|
+
return scanWalkIn(doc, animation, steps, {}, 'setup', LOOPING, scanPlant);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** `poseWalkSetup` as the scan reads it (`scanLoopingWalk`). */
|
|
195
|
+
export function scanWalkSetup(doc: CompiledDocument): ScanPose {
|
|
196
|
+
return scanSetupIn(doc, {}, LOOPING);
|
|
197
|
+
}
|
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
|
-
/**
|
|
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', '
|
|
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[]',
|
|
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,
|
|
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
|
-
}
|