spine-rigc 2.53.0 → 2.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/docs/AUTHORING.md +129 -1
- package/package.json +1 -1
- package/src/assertions/bodies/a23.ts +174 -0
- package/src/check.ts +12 -0
- package/src/cli/core_commands.ts +95 -4
- package/src/cli/shared.ts +10 -1
- package/src/core/raw.ts +29 -6
- package/src/environment.ts +193 -0
- package/src/render.ts +15 -1
- package/src/render_core.ts +10 -6
- package/src/render_shared.ts +37 -2
- package/src/timelines.ts +61 -0
package/README.md
CHANGED
|
@@ -601,7 +601,7 @@ commands take it and what its default is.
|
|
|
601
601
|
| `repack <dir> --out <dir> --keep-pages` | **`skeleton.model.json` alone, for the pages already in place** — for a player that reads the document beside an atlas and pages that already shipped. The same read and stage rules, then `build --atlas-in` over the input atlas in place of the lift and the pack; it writes the document, and nothing else, only after its `pages` are shown to state the atlas as it stands, the rebuilt skeleton byte-identical to the one on disk (so `spine.sha256` names it — no acceptance flag here) and the gate green. `--out` is usually the build's own directory and must not already hold a `skeleton.model.json`; a page or region line the document has no field for is refused by name ([AUTHORING §0.4](docs/AUTHORING.md)) |
|
|
602
602
|
| `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
|
|
603
603
|
| `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
|
|
604
|
-
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area |
|
|
604
|
+
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene |
|
|
605
605
|
| `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
|
|
606
606
|
| `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
|
|
607
607
|
| `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
|
package/docs/AUTHORING.md
CHANGED
|
@@ -246,6 +246,7 @@ What the flags mean:
|
|
|
246
246
|
| `--show`, `--dim`, `--at`, `--zoom` | `inspect` only — what it draws, whether the art is faded, which times of `--animation` it poses and the bone, slot or folds it frames to; §13 says each, with the refusals |
|
|
247
247
|
| `--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** |
|
|
248
248
|
| `--folds` | `render` only: also read which mesh triangles **fold** — turn over against the setup pose — and write a `folds.json` into each frame directory: per mesh, the frames with a folded triangle, the worst frame's count and which triangles, by index, with their world box and setup-pose area. One printed line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. A reading, not a gate. Every other file is byte for byte what the render writes without it, `geometry.json` included. Refused with `--slot`/`--hide`, for `--geometry`'s reason — **§8.3** |
|
|
249
|
+
| `--environment` | `render` only: the wind and gravity the rig's physics constraints answer, in time — a schedule file `{ "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }, …] }`, x right and **y down**, each step holding from its `t` until the next. Both posers set each change before the step that poses the first frame at or after its `t`, and draw the same frames, `geometry.json` and `frames.json` (`RE01`). `frames.json` and `geometry.json` record it, and the framing reads it. A malformed file, a half no physics constraint answers, two steps on one frame and a step after every set's last frame are refused by name, exit 2, nothing written; `check` refuses a frame set drawn under one as a reference. Without it every file is the bytes it always was. An inspection of the response the rig states, not a scene — **§8.4** |
|
|
249
250
|
| `--poser` | `render`, `inspect`, `check` and `explain`: `core` or `spine` — on `render`, `inspect` and `check`, which implementation poses the frames (on `check`, the candidate's; the reference side is pixels either way). By default a **rigc build** (the `skeleton.model.json` `build` writes; its atlas, when one is named or present, the one beside it) is posed by rigc's own core and a **Spine export** by spine-core; the render prints a `poser` line saying which and why, and so does the check report (`check.json`'s `poser`, and the pictures' `frames.json` under `--out`; `bench --frames` prints the same line). A concave or an inverse clip is posed by the core, and so is a build rendered with no `--skin` whose default skin names a skin-required bone or constraint, or whose skins name no `default` one — posed as a fresh skeleton is, no skin's bones or constraints applied (the default skin's included) and every slot resolved through the default skin alone, so the second draws nothing and stops at `there is nothing to draw` (exit 2) under either poser (issue #1051). Where the input cannot carry the core, or the core refuses it, the render poses it through spine-core and the `poser` line says why, in one of these words: `no skeleton.model.json beside` (a Spine export) · `is not beside` (`--atlas` names an atlas outside the build's directory) · `does not describe` (a model document whose bones, slots or animations are not the skeleton's) · `is not the skeleton.json` (a `skeleton.json` whose SHA-256 is not the `spine.sha256` its model document records — edited or replaced after the build — both digests named) · `the polygon is not simple` (a clip polygon two of whose edges cross or touch, the slot named) · `depends on the runtime's previous pass` (a constraint moves a posed bone from a bone that a constraint writes into while that bone is inactive, all four named) · `--poser spine` (asked for). Any other refusal of the core's — an unreadable model document among them — reads `the core refused` and then the core's own words. `--poser core` on an input that cannot carry it is refused in the same words (exit 2, nothing written); `--poser spine` forces the runtime. A fallback whose pair the runtime cannot load — another build's `skeleton.json` or atlas beside this build's document, or an export beside an atlas it was not exported with — is not drawn: it is refused `… does not load against <the atlas>: spine-core draws this pair (<why>) and could not resolve it — "<the runtime's own words>"` — or, where the atlas names a page that is not there, `nothing at <the page>: …` — exit 2, nothing written, on `render`, `render --geometry`, `check` and `bench --frames` alike; under `--poser core` the same pair is that flag's refusal, in the words above (`RC18`; the `--candidate` row says what to change). The repository's selftest renders one probe per reason above and holds this list to the lines they print (`RC09`). Both posers draw the same frames, geometry and framing box to the bit on every corpus (issue #968), and `check` through either writes the same report, `check.json` and pictures but for the poser it names on every tree row. A rigc build the core poses reads nothing through spine-core on `render`, `render --geometry` and `check`, `--texture-from` included (and the check `bench --frames` folds in; `bench`'s own `validate`, like `build`'s, is the runtime's round trip by design): its bone tree, slot list and slot roster come off its model document; so do the order its animations and skins are listed in, its stage and its pages' `scale:` lines, on a `rigc-compiled/3` document (`editorOrder`, `stage`, `pages`) — a `/2` or `/1` document states none of those, so they come off its own `skeleton.json` and atlas, and the `poser` line then says `a rigc-compiled/2 document, which does not state the order its skins and animations are listed in or its stage, read from` and names both files; where each region sits and the page images it draws off the document's `pages` (a `rigc-compiled/1` document's off the atlas beside it, and the `poser` line then says `a rigc-compiled/1 document, which does not state where each region sits on its page: that is read from` and names the atlas); and a `--texture-from` atlas off rigc's own atlas reader. The runtime is loaded for a Spine export, `--poser spine` and a fallback the `poser` line names; a run that cannot load it there is refused naming the input and why it needs the runtime (exit 1; `RC11`, `RC13`, `RC34`). On the entry that links none of spine-core — the installed `rigc` without the runtime beside it, `bun cli_core.ts` in a clone — that refusal ends on the route to the entry that does (``the entry that links it runs them — installed, the same `rigc` once @esotericsoftware/spine-core is installed beside the package; from a source checkout, `bun cli.ts` ``); a runtime that is installed and fails to load is refused without it, since installing it is not what that reader has to do. On `explain` the flag chooses the `DEFORM` block's survey (§4.11.2): `core`, the default, reads the skeleton's structure — its deform timelines and their keys, curves and slots, the skin each is keyed under, the sliders — off the model document the compile writes and poses it through the core, and spine-core is not touched (`RC15`); `spine` parses and poses the Spine skeleton through spine-core. The two are the same survey to the byte on every corpus row (`DM12`). Where the core refuses the document, the survey falls back to spine-core and the block says so on a line of its own (`read and posed through spine-core: …`); `--poser core` there is refused instead (exit 2), and a run that cannot load the runtime where it needs it is refused naming the survey and why (exit 1), ending on the same route where the entry links none of it |
|
|
250
251
|
| `--record` | `vote` only: a saved vote to check against its ballot and append to the ledger, instead of writing a ballot. This is the command's second mode; it takes no `--candidate` |
|
|
251
252
|
| `--ballot` | `vote --record` only: the ballot the vote answers (default `ballot.html`). Its embedded manifest is what the vote is checked against, so the ballot file is the record of the question |
|
|
@@ -5000,6 +5001,35 @@ file can be read; it is a **key** outside its bound that `build` refuses (§4.4)
|
|
|
5000
5001
|
number: a non-number would reach the file as `null`, which the runtime reads as
|
|
5001
5002
|
**zero**, so `"mass": "heavy"` would ship a constraint that never settles.
|
|
5002
5003
|
|
|
5004
|
+
🧮 **Each value inside its bound is not enough: the step has to settle**
|
|
5005
|
+
(issue #1416). With `step = 1 / fps`, a constraint that drives anything must
|
|
5006
|
+
satisfy
|
|
5007
|
+
|
|
5008
|
+
strength · massInverse · step² < 2 · (1 + damping^(60 · step))
|
|
5009
|
+
|
|
5010
|
+
strictly, where `massInverse` is `1 / mass`. Each substep of the integrator is a
|
|
5011
|
+
linear map whose two eigenvalues lie inside the unit circle exactly then; at or
|
|
5012
|
+
past it the offset never dies out, and past it any push — wind, gravity, a key,
|
|
5013
|
+
the bone moving — grows without limit. [measured] on 270 generated rigs, all 115
|
|
5014
|
+
past the line ran past 10⁶ units under a unit gust (most with no gust at all),
|
|
5015
|
+
all 145 under it stayed bounded, and the 10 exactly on it were still growing or
|
|
5016
|
+
swinging their tip the bone's full length when a 6 s walk ended, spine-core
|
|
5017
|
+
identically — and every one of the 270 passed every gate before this clause.
|
|
5018
|
+
The runtime's defaults
|
|
5019
|
+
(`strength` 100, `mass` 1, `damping` 0.85) sit far under it at 60 fps (0.028
|
|
5020
|
+
against 3.70) and over it at `fps: 5` (4.00 against 2.28). `A23` refuses the
|
|
5021
|
+
setup values, and — for each animation that keys `strength`, `mass` or `damping`
|
|
5022
|
+
on the constraint — the largest strength, the lightest mass and the smallest
|
|
5023
|
+
damping that animation can pose, read together (a value the animation keys first
|
|
5024
|
+
after t = 0 includes the setup value, which is what plays before that key). That
|
|
5025
|
+
joint is an upper bound: an animation that raises strength and mass together is
|
|
5026
|
+
judged at its stiffest strength against its lightest mass. **To fix it, lower
|
|
5027
|
+
`strength`, raise `mass`, raise `fps` or raise `damping`** — the first three move
|
|
5028
|
+
the left side down, the last moves the right side up (a higher `damping` number
|
|
5029
|
+
keeps more velocity per step, yet it is what widens the bound: from 2 at `0` to
|
|
5030
|
+
4 at `1`); the refusal prints both sides so you can see how far each lever has
|
|
5031
|
+
to go.
|
|
5032
|
+
|
|
5003
5033
|
⚠️ **What `rigc render` draws a spring with is not a player's clock to the bit.** A
|
|
5004
5034
|
constraint integrates whole steps of `1 / fps` and shows the rest as a lag weight, so
|
|
5005
5035
|
a frame lasting a whole number of steps (12, 20, 30 or 60 fps against the default
|
|
@@ -5046,6 +5076,10 @@ poses, to the bit (`CO56`). Two consequences an author has to know:
|
|
|
5046
5076
|
value: that would make the export pose differently in the runtime than in
|
|
5047
5077
|
the core.
|
|
5048
5078
|
|
|
5079
|
+
To look at the response before a player exists, `render --environment` (§8.4)
|
|
5080
|
+
draws the rig under a schedule of those same four numbers, on the core and on
|
|
5081
|
+
spine-core alike.
|
|
5082
|
+
|
|
5049
5083
|
Moving the rig is the consumer's too: `physicsTranslate(x, y)` and
|
|
5050
5084
|
`physicsRotate(x, y, degrees)` on a live track are the runtime's methods of
|
|
5051
5085
|
those names — the whole rig moved or turned since the last pose, in the
|
|
@@ -6701,7 +6735,7 @@ Fix A00 and run it again.
|
|
|
6701
6735
|
| <a id="A20_MESH_WEIGHTS_COHERENT"></a>`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 `rig-play` 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 |
|
|
6702
6736
|
| <a id="A21_MESH_RIM_PINNED"></a>`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 |
|
|
6703
6737
|
| <a id="A22_MESH_UVS_IN_UNIT_RANGE"></a>`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 |
|
|
6704
|
-
| <a id="A23_PHYSICS_CONSTRAINT_EFFECTIVE"></a>`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 |
|
|
6738
|
+
| <a id="A23_PHYSICS_CONSTRAINT_EFFECTIVE"></a>`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. 🧮 **A step that does not settle** (issue #1416): a constraint that drives anything, every value inside its own row, is refused when `strength·massInverse·step² ≥ 2·(1 + damping^(60·step))` with `step = 1 / fps` — the integrator's step map then has an eigenvalue on or outside the unit circle, and [measured] of 270 generated rigs, all 115 past the bound ran past 10⁶ units under a unit gust, all 145 under it stayed bounded, and the 10 exactly on it were still growing or swinging their tip the bone's full length when a 6 s walk ended. It reads the setup values, and for each animation keying `strength`, `mass` or `damping` on the constraint, the stiffest strength, the lightest mass and the smallest damping that animation poses (each key, each Bézier sample, and the setup value where the animation keys that value first after t = 0), judged together — an upper bound on any pose the animation reaches. Both sides are formed in the runtime's own order (`m = step · massInverse`, `damping ** (60 · step)`) and printed apart: `` physics "C" has strength 100, mass 1 (massInverse 1), fps 5, damping 0.85 — strength·massInverse·step² = 4.0000 must be < 2·(1 + damping^(60·step)) = 2.2845: at or past that bound the step stops settling — one step can multiply the offset by 2.807 every 1/5 s — so a push (wind, gravity, a key, the bone moving) never dies out, and past the bound it grows without limit; lower strength, raise mass, raise fps or raise damping ``; a keyed combination opens `` animation "A" can pose physics "C" at strength … `` and names the key each value came from. Fix in the **motion spec**'s physics table or its keys (§4.6). **SKIP** when the skeleton declares no physics constraint — the same sentence `A36` and `A37` print for their own constraint types |
|
|
6705
6739
|
| <a id="A24_AXIS_SPACE_STROKE"></a>`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 |
|
|
6706
6740
|
| <a id="A25_DETACHED_BONE_PARENTAGE"></a>`A25_DETACHED_BONE_PARENTAGE` | archetype | a bone the rig declares `detached` is a descendant of the bone it must never hang under |
|
|
6707
6741
|
| <a id="A26_SLOT_DRAW_ORDER"></a>`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 |
|
|
@@ -7556,6 +7590,100 @@ worst frame framed to it:
|
|
|
7556
7590
|
fills the folded triangles at that pose, frames the picture to their box, and lists
|
|
7557
7591
|
them with the same box and area in its legend (§13).
|
|
7558
7592
|
|
|
7593
|
+
### 8.4 The world the physics answers — `render --environment`
|
|
7594
|
+
|
|
7595
|
+
A physics constraint's `wind` and `gravity` are its **response** (§4.6); the world
|
|
7596
|
+
it responds to — the two directions spine-core keeps on its `Skeleton` — belongs to
|
|
7597
|
+
whoever plays the rig. A render has no player, so without this flag it draws every
|
|
7598
|
+
frame in the runtime's initial world, wind `[1, 0]` and gravity `[0, 1]`.
|
|
7599
|
+
`--environment` hands it one:
|
|
7600
|
+
|
|
7601
|
+
```bash
|
|
7602
|
+
bun cli.ts render --candidate path/to/spine --environment gust.json [--animation …] [--geometry] [--poser core|spine]
|
|
7603
|
+
```
|
|
7604
|
+
|
|
7605
|
+
```json
|
|
7606
|
+
{
|
|
7607
|
+
"spec": "rigc-environment/1",
|
|
7608
|
+
"steps": [
|
|
7609
|
+
{ "t": 0, "wind": [0, 0] },
|
|
7610
|
+
{ "t": 1, "wind": [1.5, -0.4] },
|
|
7611
|
+
{ "t": 2, "wind": [0, 0] }
|
|
7612
|
+
]
|
|
7613
|
+
}
|
|
7614
|
+
```
|
|
7615
|
+
|
|
7616
|
+
**What a step means.** From its `t`, in seconds, until the next step, the world is
|
|
7617
|
+
changed by the halves it states — `wind`, `gravity`, or both — and a half it leaves
|
|
7618
|
+
out keeps the one standing, as a live track's `setEnvironment` keeps it. Both are
|
|
7619
|
+
the runtime's own fields in the runtime's convention: x to the right, **y down** (a
|
|
7620
|
+
positive y pulls toward the bottom of the frame, as gravity `[0, 1]` does). Before
|
|
7621
|
+
the first step the world is the runtime's initial one, so a calm start is stated —
|
|
7622
|
+
`"wind": [0, 0]` at `t` 0 — rather than assumed. Each set starts the schedule at its
|
|
7623
|
+
own frame 0.
|
|
7624
|
+
|
|
7625
|
+
**When it lands.** A step's change is set before the step that poses the first
|
|
7626
|
+
frame `i` whose time `i / fps` is at or after its `t`, so the physics integrates the
|
|
7627
|
+
new world over that frame's whole interval and the frame before it is untouched.
|
|
7628
|
+
Frame 0 is the reset pose; a step at `t` 0 is set before it. A `t` that is a frame's
|
|
7629
|
+
own time — `k / fps` — lands on frame `k`.
|
|
7630
|
+
|
|
7631
|
+
**Both posers, one world.** The core sets each change on its physics before the
|
|
7632
|
+
walk's step; spine-core's poser sets the same values on its `Skeleton` (`windX,
|
|
7633
|
+
windY, gravityX, gravityY`) before the same update. The repository's selftest
|
|
7634
|
+
renders three rigs — one answering wind and gravity on every branch of the physics
|
|
7635
|
+
step, gallery/look with its whip answering wind, and a fixture answering gravity
|
|
7636
|
+
alone — under a calm → gust → calm schedule through both, and holds every PNG,
|
|
7637
|
+
`geometry.json` and `frames.json` equal (`RE01`); holds that the gust moves the rig
|
|
7638
|
+
from its own frame and no earlier (`RE02`); and plants on the core the readings the
|
|
7639
|
+
mapping rejects — the schedule ignored, its y read up, each change a frame late or
|
|
7640
|
+
early — each red against spine-core (`RE03`).
|
|
7641
|
+
|
|
7642
|
+
**The files.** `frames.json` and each `geometry.json` record the schedule as
|
|
7643
|
+
`environment`, its steps as read, `t`, `wind`, `gravity` in that order. The
|
|
7644
|
+
framing reads it too: a gust can carry a coat past the box a still rig fills, so
|
|
7645
|
+
the box is the box of what the frames under the schedule draw — and the grid can
|
|
7646
|
+
differ from the same render without the flag. Compare the two in world units, in
|
|
7647
|
+
`geometry.json`. Without the flag nothing is set and every file is the bytes it
|
|
7648
|
+
always was; the runtime's own world stated at `t` 0 draws the same pixels and
|
|
7649
|
+
geometry as no flag at all (`RE04`). `check` poses its candidate in no world but
|
|
7650
|
+
the runtime's, so a frame set drawn under a schedule is refused as a reference by
|
|
7651
|
+
name rather than scored.
|
|
7652
|
+
|
|
7653
|
+
**Refused by name**, exit 2, nothing written — each is a render that would print a
|
|
7654
|
+
plausible frame set showing nothing the schedule asked for (`RE05`):
|
|
7655
|
+
|
|
7656
|
+
- a path with nothing at it: `--environment <path>: nothing at <abs path>`;
|
|
7657
|
+
- a file that is not one: `not JSON — …`, `the file holds <what>, not an object …`,
|
|
7658
|
+
`"spec" is <found>; render reads "rigc-environment/1"`, `the file names "<key>",
|
|
7659
|
+
which is not one of [spec, steps]`, `"steps" is <found>, not a non-empty array …`;
|
|
7660
|
+
- an entry, by its index: `entry <i> of "steps" names "<key>", which is not one of
|
|
7661
|
+
[t, wind, gravity]`; `…'s "t" is <found>, not a finite time in seconds at or above
|
|
7662
|
+
0`; `…'s "t" is <found>, not after entry <i-1>'s <t> — steps are listed in
|
|
7663
|
+
ascending time, each holding until the next`; `…'s "wind" is <found>, not a pair
|
|
7664
|
+
of finite numbers [x, y] (x to the right, y down)` (`gravity` alike; a number the
|
|
7665
|
+
JSON reader makes infinite, `1e999`, is shown as `Infinity`); `… states neither
|
|
7666
|
+
"wind" nor "gravity", so it changes nothing`;
|
|
7667
|
+
- a schedule this rig would not draw: a skeleton with no animation (its one frame
|
|
7668
|
+
is the setup pose, where no physics step runs) or no physics constraint; a half
|
|
7669
|
+
the schedule changes that no physics constraint answers — `entry <i> changes
|
|
7670
|
+
wind, and no physics constraint answers it — [<names>] each state wind 0 and no
|
|
7671
|
+
animation keys it`; two steps on one frame of the render's grid —
|
|
7672
|
+
`entries [<i>, <j>] (t …) land on one frame at <fps> fps — frame <k>, t=…s — so
|
|
7673
|
+
entry <i> would never be posed`; and a step after the last frame of every set
|
|
7674
|
+
drawn.
|
|
7675
|
+
|
|
7676
|
+
**What it is, and what it is not.** It is an inspection of the response the rig
|
|
7677
|
+
states — what the Spine export carries, posed in a world you chose, on either
|
|
7678
|
+
poser. It is not a scene: when a gust blows, how strong, and what else moves is the
|
|
7679
|
+
consumer's to compose, and a schedule here is a probe of the object, not a
|
|
7680
|
+
direction of it. Moving the rig is left out on purpose: `physicsTranslate` and
|
|
7681
|
+
`physicsRotate` are a displacement since the last pose, which a step held over a
|
|
7682
|
+
span would have to restate as a velocity — a second meaning the live track does
|
|
7683
|
+
not have — and a render draws the rig where it stands, so the picture would show
|
|
7684
|
+
a coat trailing a rig that never moves. The live track holds both to spine-core
|
|
7685
|
+
(`CO56`), and a player forwards them as §4.6 says.
|
|
7686
|
+
|
|
7559
7687
|
---
|
|
7560
7688
|
|
|
7561
7689
|
## 9. Checking against the frames — `rigc check`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.54.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -24,7 +24,9 @@ import {
|
|
|
24
24
|
physicsKeyRefusal,
|
|
25
25
|
physicsOutsideSays,
|
|
26
26
|
physicsRuleFor,
|
|
27
|
+
physicsStepStability,
|
|
27
28
|
type PhysicsPoseRule,
|
|
29
|
+
type PhysicsStepStability,
|
|
28
30
|
} from '../../timelines.ts';
|
|
29
31
|
|
|
30
32
|
/**
|
|
@@ -165,6 +167,9 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
|
|
|
165
167
|
});
|
|
166
168
|
}
|
|
167
169
|
let mutedUntilKeyed = 0;
|
|
170
|
+
// Each constraint whose setup values the stability clause read, by position — the
|
|
171
|
+
// floor every animation's combination is taken from below.
|
|
172
|
+
const setupCombos = new Map<number, StabilityCombo>();
|
|
168
173
|
facts.constraints.forEach((constraint, index) => {
|
|
169
174
|
const physics = constraint.physics;
|
|
170
175
|
if (physics === undefined) return;
|
|
@@ -245,6 +250,24 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
|
|
|
245
250
|
if (physics.step <= 0 || !Number.isFinite(physics.step)) {
|
|
246
251
|
fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has step ${physics.step} (fps must be > 0)`);
|
|
247
252
|
}
|
|
253
|
+
// --- the step itself has to settle (issue #1416, part B) ------------------
|
|
254
|
+
//
|
|
255
|
+
// The rows above judge each value against its own interval; this is the one
|
|
256
|
+
// joint condition, `physicsStepStability`'s. It is read only where the rows
|
|
257
|
+
// it combines all hold — a value its own row refuses is named there, and a
|
|
258
|
+
// second line about the same number would be the same fault twice — and
|
|
259
|
+
// only on a constraint that drives something, which the first clause names
|
|
260
|
+
// otherwise.
|
|
261
|
+
if (components.length && stableInputs(pose, physics.step)) {
|
|
262
|
+
const setupCombo: StabilityCombo = {
|
|
263
|
+
strength: { value: pose.strength, from: 'setup' },
|
|
264
|
+
massInverse: { value: pose.massInverse, from: 'setup' },
|
|
265
|
+
damping: { value: pose.damping, from: 'setup' },
|
|
266
|
+
};
|
|
267
|
+
const at = physicsStepStability(pose.strength, pose.massInverse, pose.damping, physics.step);
|
|
268
|
+
if (!at.stable) fail('A23_PHYSICS_CONSTRAINT_EFFECTIVE', `${where} has ${runawaySays(setupCombo, physics.step, at)}`);
|
|
269
|
+
setupCombos.set(index, setupCombo);
|
|
270
|
+
}
|
|
248
271
|
});
|
|
249
272
|
// What the PASS would otherwise not say: this rig rests with physics off
|
|
250
273
|
// on that many constraints and an animation is what switches them on. A
|
|
@@ -302,4 +325,155 @@ export function a23PhysicsConstraintEffective({ fail, skip, stats }: Verdicts, f
|
|
|
302
325
|
}
|
|
303
326
|
}
|
|
304
327
|
stats.physicsTimelineKeys = keysRead;
|
|
328
|
+
|
|
329
|
+
// --- the step has to settle on every pose an animation reaches (#1416) ----
|
|
330
|
+
//
|
|
331
|
+
// 🔑 **Which combinations are read.** Per animation and per constraint its
|
|
332
|
+
// `strength`, `mass` or `damping` timelines reach (a named one, or each a
|
|
333
|
+
// global one reaches): the LARGEST strength, the LARGEST massInverse and the
|
|
334
|
+
// SMALLEST damping that animation can pose on that constraint, judged
|
|
335
|
+
// together. A value's candidates are every key and every Bézier sample of
|
|
336
|
+
// every such timeline, as the pose field it lands in (`posed`) — and the
|
|
337
|
+
// setup value as well wherever the animation keys that value not at all, or
|
|
338
|
+
// keys it first after t = 0, because before its first key a timeline poses the
|
|
339
|
+
// setup value. Each extreme is exact for its own value: the runtime moves
|
|
340
|
+
// a value linearly between those points, `massInverse` is monotone in the
|
|
341
|
+
// mass it inverts, and the bound is monotone in damping.
|
|
342
|
+
//
|
|
343
|
+
// ⚠️ The JOINT is an upper bound rather than a time-aligned pose: an
|
|
344
|
+
// animation that raises strength while it raises mass, so the ratio holds,
|
|
345
|
+
// is judged at the larger strength against the smaller mass. That is the
|
|
346
|
+
// conservative direction — no pose this animation reaches is less stable
|
|
347
|
+
// than the combination read — and it was chosen over evaluating the three
|
|
348
|
+
// curves at shared times because the facts carry each curve's values and
|
|
349
|
+
// not its sample times, and because a single held key crossing the bound is
|
|
350
|
+
// already a runaway. A line names which key or setup supplied each value, so
|
|
351
|
+
// a refusal of that kind says so.
|
|
352
|
+
//
|
|
353
|
+
// A combination equal to the setup's is not repeated: the setup line names
|
|
354
|
+
// it already. A value its own row refuses is left out of the extremes — it
|
|
355
|
+
// was named above, by its key.
|
|
356
|
+
const keyed = new Map<string, Map<number, Partial<Record<StabilityWord, Candidate[]>>>>();
|
|
357
|
+
for (const timeline of facts.timelines) {
|
|
358
|
+
if (timeline.kind !== 'physics' || !isStabilityWord(timeline.word)) continue;
|
|
359
|
+
const rule = physicsRuleFor(timeline.word);
|
|
360
|
+
if (rule === undefined) continue;
|
|
361
|
+
const word = timeline.word;
|
|
362
|
+
const values = timeline.channelValues(0);
|
|
363
|
+
const firstTime = timeline.frames[0]?.time ?? 0;
|
|
364
|
+
const candidates: Candidate[] = [];
|
|
365
|
+
// `channelValues` lists each key and then the Bézier samples of the segment
|
|
366
|
+
// it opens, so the next key's value is that key and anything else is the
|
|
367
|
+
// curve leaving the key before it.
|
|
368
|
+
let next = 0;
|
|
369
|
+
for (const value of values) {
|
|
370
|
+
const frame = timeline.frames[next];
|
|
371
|
+
const isKey = frame !== undefined && frame.value === value;
|
|
372
|
+
if (isKey) next++;
|
|
373
|
+
const posedValue = timeline.posed === undefined ? rule.toPose(value) : timeline.posed(value);
|
|
374
|
+
if (physicsKeyRefusal(rule, value, posedValue) !== null) continue;
|
|
375
|
+
candidates.push({
|
|
376
|
+
value: posedValue,
|
|
377
|
+
from: isKey ? `the key at t=${frame.time.toFixed(6)}s` : `the curve after the key at t=${(timeline.frames[next - 1]?.time ?? 0).toFixed(6)}s`,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
const byConstraint = keyed.get(timeline.animation) ?? new Map<number, Partial<Record<StabilityWord, Candidate[]>>>();
|
|
381
|
+
keyed.set(timeline.animation, byConstraint);
|
|
382
|
+
for (const index of timeline.reach) {
|
|
383
|
+
const setup = setupCombos.get(index);
|
|
384
|
+
if (setup === undefined) continue;
|
|
385
|
+
const slot = byConstraint.get(index) ?? {};
|
|
386
|
+
byConstraint.set(index, slot);
|
|
387
|
+
const list = slot[word] ?? [];
|
|
388
|
+
list.push(...candidates);
|
|
389
|
+
// Before the first key the timeline poses the setup value.
|
|
390
|
+
if (firstTime > 0) list.push(setup[STABILITY_FIELD[word]]);
|
|
391
|
+
slot[word] = list;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
for (const [animation, byConstraint] of keyed) {
|
|
395
|
+
for (const [index, slot] of byConstraint) {
|
|
396
|
+
const setup = setupCombos.get(index);
|
|
397
|
+
const physics = facts.constraints[index]?.physics;
|
|
398
|
+
if (setup === undefined || physics === undefined) continue;
|
|
399
|
+
const pick = (word: StabilityWord, worse: (a: number, b: number) => boolean): Candidate => {
|
|
400
|
+
const list = slot[word];
|
|
401
|
+
// A value this animation never keys, or keys only with values refused above, stays at setup.
|
|
402
|
+
if (list === undefined || list.length === 0) return setup[STABILITY_FIELD[word]];
|
|
403
|
+
return list.reduce((best, one) => (worse(one.value, best.value) ? one : best));
|
|
404
|
+
};
|
|
405
|
+
const combo: StabilityCombo = {
|
|
406
|
+
strength: pick('strength', (a, b) => a > b),
|
|
407
|
+
massInverse: pick('mass', (a, b) => a > b),
|
|
408
|
+
damping: pick('damping', (a, b) => a < b),
|
|
409
|
+
};
|
|
410
|
+
if (combo.strength.value === setup.strength.value && combo.massInverse.value === setup.massInverse.value && combo.damping.value === setup.damping.value) continue;
|
|
411
|
+
const at = physicsStepStability(combo.strength.value, combo.massInverse.value, combo.damping.value, physics.step);
|
|
412
|
+
if (at.stable) continue;
|
|
413
|
+
fail(
|
|
414
|
+
'A23_PHYSICS_CONSTRAINT_EFFECTIVE',
|
|
415
|
+
`animation "${animation}" can pose physics "${facts.constraints[index]?.name ?? `#${index}`}" at ${runawaySays(combo, physics.step, at)}`,
|
|
416
|
+
);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** The three timelines whose values the stability clause combines. */
|
|
422
|
+
type StabilityWord = 'strength' | 'mass' | 'damping';
|
|
423
|
+
const STABILITY_FIELD: Record<StabilityWord, keyof StabilityCombo> = { strength: 'strength', mass: 'massInverse', damping: 'damping' };
|
|
424
|
+
function isStabilityWord(word: string): word is StabilityWord {
|
|
425
|
+
return word === 'strength' || word === 'mass' || word === 'damping';
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/** One value the stability clause read, and where it came from: `setup`, or a key or curve of the animation. */
|
|
429
|
+
interface Candidate {
|
|
430
|
+
readonly value: number;
|
|
431
|
+
readonly from: string;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/** The three pose values the stability clause judges together. */
|
|
435
|
+
interface StabilityCombo {
|
|
436
|
+
readonly strength: Candidate;
|
|
437
|
+
readonly massInverse: Candidate;
|
|
438
|
+
readonly damping: Candidate;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Whether every value the stability clause combines is inside its own row, so the joint condition is the only one left to read. */
|
|
442
|
+
function stableInputs(pose: PhysicsSetup, step: number): boolean {
|
|
443
|
+
if (!(step > 0) || !Number.isFinite(step)) return false;
|
|
444
|
+
return PHYSICS_POSE_RULES.every((rule) => rule.timeline === 'mix' || rule.poseOk(pose[rule.field]));
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/** A number as an author typed it: `1 / (1 / 45)` prints 45 rather than its last-bit neighbour. */
|
|
448
|
+
function plain(value: number): string {
|
|
449
|
+
return String(Number(value.toPrecision(12)));
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* The stability clause's sentence: the four authored numbers, `k` and the bound
|
|
454
|
+
* as the runtime forms them, what the step does at that pose, and the four
|
|
455
|
+
* levers that move it back under the bound.
|
|
456
|
+
*/
|
|
457
|
+
function runawaySays(combo: StabilityCombo, step: number, at: PhysicsStepStability): string {
|
|
458
|
+
const src = (c: Candidate): string => (c.from === 'setup' ? '' : ` (${c.from})`);
|
|
459
|
+
const fps = plain(1 / step);
|
|
460
|
+
const places = apart(at.k, at.bound, 4);
|
|
461
|
+
return (
|
|
462
|
+
`strength ${combo.strength.value}${src(combo.strength)}, mass ${plain(1 / combo.massInverse.value)} (massInverse ${combo.massInverse.value})${src(combo.massInverse)}, ` +
|
|
463
|
+
`fps ${fps}, damping ${combo.damping.value}${src(combo.damping)} — strength·massInverse·step² = ${at.k.toFixed(places)} must be < ` +
|
|
464
|
+
`2·(1 + damping^(60·step)) = ${at.bound.toFixed(places)}: at or past that bound the step stops settling — one step can ` +
|
|
465
|
+
`multiply the offset by ${at.growth.toFixed(apart(at.growth, 1, 3))} every 1/${fps} s — so a push (wind, gravity, a key, the ` +
|
|
466
|
+
'bone moving) never dies out, and past the bound it grows without limit; lower strength, raise mass, raise fps or raise damping'
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* The fewest decimal places, from `min`, at which `a` and `b` print apart — so
|
|
472
|
+
* a value one part in 10⁹ past the bound does not print as equal to it. Two
|
|
473
|
+
* equal numbers print at `min`.
|
|
474
|
+
*/
|
|
475
|
+
function apart(a: number, b: number, min: number): number {
|
|
476
|
+
if (a === b) return min;
|
|
477
|
+
for (let places = min; places < 17; places++) if (a.toFixed(places) !== b.toFixed(places)) return places;
|
|
478
|
+
return 17;
|
|
305
479
|
}
|
package/src/check.ts
CHANGED
|
@@ -1286,6 +1286,18 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
1286
1286
|
`${options.skin === undefined ? 'with no --skin' : `with --skin ${JSON.stringify(options.skin)}`}.`,
|
|
1287
1287
|
);
|
|
1288
1288
|
}
|
|
1289
|
+
// A frame set drawn under `render --environment` (issue #1416) is a picture of the rig in a world `check` does not
|
|
1290
|
+
// pose: it poses the candidate under the runtime's own wind and gravity, so the score would be a number about the
|
|
1291
|
+
// difference between two worlds. Refused by name, as a subset is, rather than scored.
|
|
1292
|
+
const referenceEnvironment = located.sidecar?.environment;
|
|
1293
|
+
if (referenceEnvironment !== undefined) {
|
|
1294
|
+
throw new CheckError(
|
|
1295
|
+
`${FRAMES_SIDECAR} records that these frames were rendered under an environment schedule (\`rigc render ` +
|
|
1296
|
+
`--environment\`, ${Array.isArray(referenceEnvironment) ? referenceEnvironment.length : 0} step(s)), and check poses the candidate ` +
|
|
1297
|
+
"under none — the runtime's own wind [1, 0] and gravity [0, 1] — so the comparison would be a number about the " +
|
|
1298
|
+
'difference between two worlds. Render the reference frames without --environment.',
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1289
1301
|
if (referenceSkin === null && options.skin !== undefined) {
|
|
1290
1302
|
notes.push(
|
|
1291
1303
|
`the candidate is posed under skin ${JSON.stringify(options.skin)} and the reference frames record no skin ` +
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -32,6 +32,7 @@ import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
|
|
|
32
32
|
import { parseMotionSpec } from '../motion.ts';
|
|
33
33
|
import { type InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, readTimes, readZoom } from '../inspect.ts';
|
|
34
34
|
import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine } from '../folds.ts';
|
|
35
|
+
import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
|
|
35
36
|
import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
|
|
36
37
|
import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
|
|
37
38
|
import { type CompileResult } from '../types.ts';
|
|
@@ -472,6 +473,72 @@ function readPositiveNumber(flags: Record<string, string>, key: string, fallback
|
|
|
472
473
|
return value;
|
|
473
474
|
}
|
|
474
475
|
|
|
476
|
+
/**
|
|
477
|
+
* `--environment <schedule.json>` read (issue #1416, `../environment.ts`), or
|
|
478
|
+
* refused as a usage error before anything is read off the candidate: a path
|
|
479
|
+
* with nothing at it, a file that is not a schedule, and every malformed entry,
|
|
480
|
+
* by its index, its field, the value found and what is required.
|
|
481
|
+
*/
|
|
482
|
+
function readEnvironmentFlag(flags: Record<string, string>): { path: string; steps: EnvironmentStep[] } | undefined {
|
|
483
|
+
const given = flags.environment;
|
|
484
|
+
if (given === undefined) return undefined;
|
|
485
|
+
const path = resolve(given);
|
|
486
|
+
if (!existsSync(path) || !statSync(path).isFile()) throw new UsageError(`--environment ${given}: nothing at ${path} — it takes a schedule file { "spec": "rigc-environment/1", "steps": [...] }`);
|
|
487
|
+
try {
|
|
488
|
+
return { path, steps: readEnvironmentSchedule(readFileSync(path, 'utf8'), given) };
|
|
489
|
+
} catch (err) {
|
|
490
|
+
if (err instanceof EnvironmentError) throw new UsageError(err.message);
|
|
491
|
+
throw err;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* The refusals of a well-formed schedule that this candidate, at this rate,
|
|
497
|
+
* would quietly not draw (issue #1416) — each one a render that would print a
|
|
498
|
+
* plausible frame set showing nothing the schedule asked for:
|
|
499
|
+
*
|
|
500
|
+
* - a skeleton with no animation, whose one frame is the setup pose, where no
|
|
501
|
+
* physics step runs;
|
|
502
|
+
* - a half the schedule changes that no physics constraint answers — none
|
|
503
|
+
* states it non-zero and no animation keys it — so every frame would draw as
|
|
504
|
+
* without the flag;
|
|
505
|
+
* - two steps on one frame of the render's grid, the first never posed;
|
|
506
|
+
* - a step after the last frame of every set drawn.
|
|
507
|
+
*
|
|
508
|
+
* `durations` are the poser's, of the sets this run draws.
|
|
509
|
+
*/
|
|
510
|
+
function environmentRefusal(given: string, steps: readonly EnvironmentStep[], skeletonText: string, fps: number, durations: ReadonlyArray<{ name: string; duration: number }>): string | null {
|
|
511
|
+
if (durations.length === 0) return `--environment ${given}: the skeleton has no animation — its one frame is the setup pose, where no physics step runs, so no environment moves it`;
|
|
512
|
+
const response = environmentResponse(skeletonText);
|
|
513
|
+
if (response.declared.length === 0) return `--environment ${given}: the skeleton declares no physics constraint, so no environment moves anything in it`;
|
|
514
|
+
for (const half of ['wind', 'gravity'] as const) {
|
|
515
|
+
const changing = steps.map((step, i) => (step[half] === undefined ? -1 : i)).filter((i) => i >= 0);
|
|
516
|
+
if (changing.length > 0 && response[half].length === 0) {
|
|
517
|
+
return (
|
|
518
|
+
`--environment ${given}: entry ${changing[0]} changes ${half}, and no physics constraint answers it — [${response.declared.map((n) => JSON.stringify(n)).join(', ')}] each state ${half} 0 and no animation keys it — ` +
|
|
519
|
+
`so every frame would draw as it does without the schedule. A constraint's own ${half} is its response (AUTHORING §4.6)`
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const collision = environmentCollisions(steps, fps)[0];
|
|
524
|
+
if (collision !== undefined) {
|
|
525
|
+
return (
|
|
526
|
+
`--environment ${given}: entries [${collision.entries.join(', ')}] (t ${collision.entries.map((i) => steps[i].t).join(', ')}) land on one frame at ${fps} fps — frame ${collision.frame}, ` +
|
|
527
|
+
`t=${(collision.frame / fps).toFixed(4)}s — so entry ${collision.entries[0]} would never be posed. Raise --fps, or merge them`
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
const lastFrames = durations.map(({ name, duration }) => ({ name, last: Math.round(duration * fps) }));
|
|
531
|
+
const longest = lastFrames.reduce((a, b) => (b.last > a.last ? b : a));
|
|
532
|
+
const late = steps.findIndex((step) => environmentFrame(step.t, fps) > longest.last);
|
|
533
|
+
if (late >= 0) {
|
|
534
|
+
return (
|
|
535
|
+
`--environment ${given}: entry ${late}'s t ${steps[late].t} falls after the last frame of every set drawn — the longest, "${longest.name}", ends at frame ${longest.last}, ` +
|
|
536
|
+
`t=${(longest.last / fps).toFixed(4)}s at ${fps} fps — so it would change nothing`
|
|
537
|
+
);
|
|
538
|
+
}
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
|
|
475
542
|
/**
|
|
476
543
|
* render — the frame series, drawn by the same rasteriser `check` measures with.
|
|
477
544
|
*
|
|
@@ -487,6 +554,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
487
554
|
const { skeletonPath, atlasPath, atlasText } = resolveDrawable(flags.candidate, flags.atlas);
|
|
488
555
|
const atlasDir = dirname(atlasPath);
|
|
489
556
|
const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
|
|
557
|
+
// Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
|
|
558
|
+
const environment = readEnvironmentFlag(flags);
|
|
490
559
|
const maxSide = readPositiveNumber(flags, 'max', 256, 16);
|
|
491
560
|
const outRoot = resolve(flags.out ?? 'render');
|
|
492
561
|
const geometry = flags.geometry !== undefined;
|
|
@@ -532,8 +601,9 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
532
601
|
// core poses — `skeleton.model.json` beside the pair — reads its names, its
|
|
533
602
|
// subset roster and its pages without loading spine-core at all, and with a
|
|
534
603
|
// rigc-compiled/2 document without its atlas either (issue #1020).
|
|
604
|
+
const skeletonText = readSkeletonText(skeletonPath);
|
|
535
605
|
const { choice, facts, pages } = loadCandidate(
|
|
536
|
-
{ skeletonText
|
|
606
|
+
{ skeletonText, atlasText, atlasDir, label: skeletonPath },
|
|
537
607
|
{ skeleton: skeletonPath, atlas: atlasPath },
|
|
538
608
|
posersAsked(flags),
|
|
539
609
|
);
|
|
@@ -546,12 +616,17 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
546
616
|
// `src/render.ts` has one of its own. The inferred shape is the render one.
|
|
547
617
|
// The subset rides on the same object and `framingViewport` takes it off, so
|
|
548
618
|
// the frames draw the subset and the box is still the whole rig's.
|
|
619
|
+
// The environment rides on the same object for the same reason (issue #1416): the framing reads it, so the box holds
|
|
620
|
+
// what the frames under it draw.
|
|
549
621
|
const pose =
|
|
550
|
-
skin === undefined && subset === undefined
|
|
622
|
+
skin === undefined && subset === undefined && environment === undefined
|
|
551
623
|
? undefined
|
|
552
|
-
: { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset) };
|
|
624
|
+
: { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset), ...(environment === undefined ? {} : { environment: environment.steps }) };
|
|
553
625
|
if (skin !== undefined) console.log(` .. skin ${skin}`);
|
|
554
626
|
if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
|
|
627
|
+
if (environment !== undefined && facts.animations.length === 0) {
|
|
628
|
+
throw new UsageError(environmentRefusal(flags.environment, environment.steps, skeletonText, fps, []) ?? '');
|
|
629
|
+
}
|
|
555
630
|
|
|
556
631
|
// Rigc's own core when the candidate is a rigc build, and spine-core
|
|
557
632
|
// otherwise, or where the core refuses the input by name. Never silently:
|
|
@@ -565,6 +640,13 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
565
640
|
// re-poses the whole input on spine-core rather than leaving half of a frame
|
|
566
641
|
// set drawn by each.
|
|
567
642
|
const posed = throughPoser(choice, (poser, roster) => {
|
|
643
|
+
// A schedule this candidate would not draw is refused before anything is posed, off the durations of the poser that
|
|
644
|
+
// poses it (issue #1416) — a fallback re-runs this with the other poser's, which the core holds equal.
|
|
645
|
+
if (environment !== undefined) {
|
|
646
|
+
const drawn = poser.animations.filter((a) => only === undefined || a.name === only);
|
|
647
|
+
const refusal = environmentRefusal(flags.environment, environment.steps, skeletonText, fps, drawn);
|
|
648
|
+
if (refusal !== null) throw new UsageError(refusal);
|
|
649
|
+
}
|
|
568
650
|
// `null` is a skeleton that posed no vertex at all. One that posed a vertex
|
|
569
651
|
// it cannot frame — Infinity or NaN — is thrown from the framing as a
|
|
570
652
|
// `GeometryError` naming the number (issue #873), and one whose every vertex
|
|
@@ -598,7 +680,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
598
680
|
if (geometry) {
|
|
599
681
|
for (const [name, frames] of sampled) {
|
|
600
682
|
const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
|
|
601
|
-
geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin)));
|
|
683
|
+
geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps)));
|
|
602
684
|
}
|
|
603
685
|
}
|
|
604
686
|
// The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
|
|
@@ -616,6 +698,13 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
616
698
|
});
|
|
617
699
|
const { viewport, sampled, geometryTexts, foldReadings } = posed.value;
|
|
618
700
|
console.log(` .. poser ${posed.note}`);
|
|
701
|
+
if (environment !== undefined) {
|
|
702
|
+
const response = environmentResponse(skeletonText);
|
|
703
|
+
console.log(
|
|
704
|
+
` .. world ${environment.steps.length} step(s) from ${environment.path} (x right, y down; the runtime's wind [1, 0] and gravity [0, 1] before the first) — ` +
|
|
705
|
+
`wind answered by ${response.wind.join(', ') || 'none'}; gravity by ${response.gravity.join(', ') || 'none'}`,
|
|
706
|
+
);
|
|
707
|
+
}
|
|
619
708
|
console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
|
|
620
709
|
if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
|
|
621
710
|
|
|
@@ -682,6 +771,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
682
771
|
// Written only when a subset was asked for, for the same reason: a render of
|
|
683
772
|
// every slot says nothing and stays the bytes it always was (issue #835).
|
|
684
773
|
...subsetFields(subset),
|
|
774
|
+
// Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
|
|
775
|
+
...(environment === undefined ? {} : { environment: environment.steps }),
|
|
685
776
|
background: BACKGROUND,
|
|
686
777
|
// The one spelling `geometry.json` repeats, so the two files cannot state
|
|
687
778
|
// two boxes for one grid.
|
package/src/cli/shared.ts
CHANGED
|
@@ -1013,6 +1013,13 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1013
1013
|
'with where they are (the world box of their posed vertices) and how big (their setup-pose area, and its share of the mesh\'s). ' +
|
|
1014
1014
|
'One line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. ' +
|
|
1015
1015
|
'A reading, not a gate. Not with --slot/--hide: it reads every mesh the pose shows',
|
|
1016
|
+
environment:
|
|
1017
|
+
'the world the rig\'s physics answers, in time: a file { "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, ' +
|
|
1018
|
+
'"wind"?: [x, y], "gravity"?: [x, y] }, …] }, x right and y down, each step holding from its t until the next and set ' +
|
|
1019
|
+
'before the step that poses the first frame at or after it, on either poser. Before the first step the world is the ' +
|
|
1020
|
+
'runtime\'s, wind [1, 0] and gravity [0, 1]. frames.json and geometry.json record it, and the framing reads it. A ' +
|
|
1021
|
+
'malformed file, a half no physics constraint answers, two steps on one frame and a step after every set\'s last frame ' +
|
|
1022
|
+
'are refused by name. An inspection of what the rig offers, not a scene',
|
|
1016
1023
|
poser:
|
|
1017
1024
|
"`render`, `inspect` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
|
|
1018
1025
|
'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
|
|
@@ -1194,6 +1201,7 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1194
1201
|
'pack-shape': 'rect|polygon',
|
|
1195
1202
|
'texture-from': '<path>',
|
|
1196
1203
|
poser: 'core|spine',
|
|
1204
|
+
environment: '<schedule.json>',
|
|
1197
1205
|
reference: '<dir|skeleton.json>',
|
|
1198
1206
|
'reference-atlas': '<path>',
|
|
1199
1207
|
bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
|
|
@@ -1599,8 +1607,9 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1599
1607
|
usage: [
|
|
1600
1608
|
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
|
|
1601
1609
|
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
1610
|
+
'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
|
|
1602
1611
|
],
|
|
1603
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'poser', 'out'],
|
|
1612
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'environment', 'poser', 'out'],
|
|
1604
1613
|
overrides: {
|
|
1605
1614
|
out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
|
|
1606
1615
|
fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
|
package/src/core/raw.ts
CHANGED
|
@@ -499,8 +499,26 @@ export function poseRawAnimation(doc: CompiledDocument, animation: string, steps
|
|
|
499
499
|
* (`physicsClockStep` below, issue #1380); the track time and
|
|
500
500
|
* the animation time are the steps' running sum either way.
|
|
501
501
|
*/
|
|
502
|
-
export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void, clock?: (dt: number) => number): void {
|
|
503
|
-
walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit);
|
|
502
|
+
export function poseRawAnimationEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void, clock?: (dt: number) => number, environment?: StepEnvironment): void {
|
|
503
|
+
walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit, environment);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* What a batch walk sets on its physics before a pose (issue #1416, part C):
|
|
508
|
+
* pose `index`'s change to the world every physics constraint answers — either
|
|
509
|
+
* half, merged over the one standing as a live track's `setEnvironment` merges
|
|
510
|
+
* it (`setEnvironmentOn`) — or `undefined` for none. Pose 0 is the reset pose;
|
|
511
|
+
* pose `k + 1` is the one step `k` makes. `rigc render --environment` is the
|
|
512
|
+
* caller (`src/render_core.ts`), and spine-core's poser sets the same values on
|
|
513
|
+
* its `Skeleton` before the same update (`src/render.ts`), so the two posers
|
|
514
|
+
* see one world at every step.
|
|
515
|
+
*/
|
|
516
|
+
export type StepEnvironment = (index: number) => Partial<PhysicsEnvironment> | undefined;
|
|
517
|
+
|
|
518
|
+
/** `environment`'s change for pose `index`, set on `ctx` as a live track sets one — validated and merged by `setEnvironmentOn`. */
|
|
519
|
+
function setStepEnvironment(ctx: PhysicsStepContext, environment: StepEnvironment | undefined, index: number): void {
|
|
520
|
+
const change = environment?.(index);
|
|
521
|
+
if (change !== undefined) setEnvironmentOn(ctx, change, `the walk's, at pose ${index},`);
|
|
504
522
|
}
|
|
505
523
|
|
|
506
524
|
/**
|
|
@@ -755,10 +773,12 @@ function animationOf(doc: CompiledDocument, animation: string): CompiledDocument
|
|
|
755
773
|
* `reset: 'setup'` pose 0 is the setup pose, reset; under `animation` it is the
|
|
756
774
|
* animation applied at 0 and reset there, a step of 0 (`stepWalk`'s `first`).
|
|
757
775
|
*/
|
|
758
|
-
function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, adjust?: LocalAdjust): { track: TrackState<P>; first: P } {
|
|
776
|
+
function openWalk<P>(doc: CompiledDocument, anim: CompiledDocument['animations'][number], plant: TimelinePlant, reset: RawReset, mode: WalkMode, shape: PoseShape<P>, adjust?: LocalAdjust, before?: (ctx: PhysicsStepContext) => void): { track: TrackState<P>; first: P } {
|
|
759
777
|
const why = constraintsAbsentWhy(doc) ?? pathAnimationsWhy(doc) ?? steppedPreviousPassWhy(doc);
|
|
760
778
|
if (why !== null) throw new CoreInputError(`the raw walk leaves the bones out: ${why}`);
|
|
761
779
|
const ctx = freshStepContext(plant.physicsStep);
|
|
780
|
+
// What the caller sets before pose 0 (issue #1416, part C): the batch walk's environment for the reset pose.
|
|
781
|
+
before?.(ctx);
|
|
762
782
|
if (mode.loop && mode.wrapResets !== false) ctx.loop = true;
|
|
763
783
|
const track: TrackState<P> = { doc, anim, duration: anim.timelines.duration, mode, plant, raw: { ...plant, ...roundPlant(mode) }, resolve: plant.shown ?? shownAttachment, shape, ctx, adjust, trackTime: 0, last: -1 };
|
|
764
784
|
if (reset === 'setup') {
|
|
@@ -890,14 +910,17 @@ export function openRawTracksPath(doc: CompiledDocument, plant: TimelinePlant):
|
|
|
890
910
|
}
|
|
891
911
|
|
|
892
912
|
/** `walkIn`'s walk, each pose assembled in `shape` and handed to `visit` in order as it is posed (`poseRawAnimationEach`): the walk opened, then stepped over `steps`. */
|
|
893
|
-
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 {
|
|
913
|
+
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, environment?: StepEnvironment): void {
|
|
894
914
|
const anim = animationOf(doc, animation);
|
|
895
915
|
const bad = steps.findIndex((s) => !Number.isFinite(s) || s < 0);
|
|
896
916
|
if (bad >= 0) throw new CoreInputError(`step ${bad} is ${steps[bad]}, not a finite time at or above 0`);
|
|
897
|
-
const { track, first } = openWalk(doc, anim, plant, reset, mode, shape);
|
|
917
|
+
const { track, first } = openWalk(doc, anim, plant, reset, mode, shape, undefined, environment === undefined ? undefined : (ctx) => setStepEnvironment(ctx, environment, 0));
|
|
898
918
|
let visited = 0;
|
|
899
919
|
visit(first, visited++);
|
|
900
|
-
for (const dt of steps)
|
|
920
|
+
for (const dt of steps) {
|
|
921
|
+
setStepEnvironment(track.ctx, environment, visited);
|
|
922
|
+
visit(stepWalk(track, dt), visited++);
|
|
923
|
+
}
|
|
901
924
|
}
|
|
902
925
|
|
|
903
926
|
/**
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The world a render's physics answers, in time — `rigc render --environment`
|
|
3
|
+
* (issue #1416, part C).
|
|
4
|
+
*
|
|
5
|
+
* A rig's physics constraints state how much each one answers wind and
|
|
6
|
+
* gravity (their own `wind` and `gravity`, AUTHORING §4.6). The world they
|
|
7
|
+
* answer is the consumer's: the two direction vectors spine-core keeps on its
|
|
8
|
+
* `Skeleton` (`windX, windY`, `gravityX, gravityY`), which a live track sets
|
|
9
|
+
* with `setEnvironment` (`./core/track.ts`). A render has no consumer, so this
|
|
10
|
+
* file is one: a list of steps in time, each changing the world from its `t`
|
|
11
|
+
* until the next, which both render posers set before the step that poses each
|
|
12
|
+
* frame (`src/render_core.ts`, `src/render.ts`).
|
|
13
|
+
*
|
|
14
|
+
* ⭐ It inspects what the rig offers; it does not compose a scene. Which gust
|
|
15
|
+
* blows when is the consumer's, and a schedule here is a probe of the response
|
|
16
|
+
* — the same probe on both posers, so the export's answer to it is held to the
|
|
17
|
+
* core's (the render-environment suite).
|
|
18
|
+
*
|
|
19
|
+
* The coordinates are the runtime's own and the live track's: x to the right,
|
|
20
|
+
* **y down** (a positive y pulls toward the bottom of the screen, as the
|
|
21
|
+
* runtime's initial gravity `[0, 1]` does). Until the first step the world is
|
|
22
|
+
* the runtime's initial one, wind `[1, 0]` and gravity `[0, 1]`, which is what a
|
|
23
|
+
* render without the flag answers — so a calm start has to be stated.
|
|
24
|
+
*
|
|
25
|
+
* Nothing here links spine-core, and nothing here reads a clock: the schedule
|
|
26
|
+
* is resolved onto a frame grid by arithmetic on the frame index alone.
|
|
27
|
+
*/
|
|
28
|
+
import type { PhysicsEnvironment } from './core/constraints_physics.ts';
|
|
29
|
+
|
|
30
|
+
/** The tag a schedule file states, refused by name when it states another. */
|
|
31
|
+
export const ENVIRONMENT_SPEC = 'rigc-environment/1';
|
|
32
|
+
|
|
33
|
+
/** One pose's change to the world: either half, the other kept as it stands. */
|
|
34
|
+
export type EnvironmentChange = Partial<PhysicsEnvironment>;
|
|
35
|
+
|
|
36
|
+
/** One step of a schedule: from `t` seconds on, until the next step, the world is changed by `wind` and/or `gravity`. */
|
|
37
|
+
export interface EnvironmentStep {
|
|
38
|
+
t: number;
|
|
39
|
+
wind?: [number, number];
|
|
40
|
+
gravity?: [number, number];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** A schedule refused — by entry index, field, the value found and what is required. */
|
|
44
|
+
export class EnvironmentError extends Error {}
|
|
45
|
+
|
|
46
|
+
const TOP_KEYS = ['spec', 'steps'] as const;
|
|
47
|
+
const STEP_KEYS = ['t', 'wind', 'gravity'] as const;
|
|
48
|
+
const PAIR = 'a pair of finite numbers [x, y] (x to the right, y down)';
|
|
49
|
+
|
|
50
|
+
/** A value as a refusal shows it: JSON, except that a number that is not finite is written as itself rather than as `null`. */
|
|
51
|
+
function shown(value: unknown): string {
|
|
52
|
+
if (typeof value === 'number') return String(value);
|
|
53
|
+
if (Array.isArray(value)) return `[${value.map(shown).join(', ')}]`;
|
|
54
|
+
if (value === undefined) return 'nothing';
|
|
55
|
+
return JSON.stringify(value);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** What a value is, for the refusal of a file or an entry that is not an object. */
|
|
59
|
+
function kindOf(value: unknown): string {
|
|
60
|
+
if (value === null) return 'null';
|
|
61
|
+
if (Array.isArray(value)) return 'an array';
|
|
62
|
+
return `${typeof value === 'object' ? 'an' : 'a'} ${typeof value} ${typeof value === 'object' ? '' : shown(value)}`.trimEnd();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A schedule file's text read, or refused by name before anything is posed.
|
|
67
|
+
* `where` is how the refusal names the file. Every step is returned with its
|
|
68
|
+
* keys in one order — `t`, `wind`, `gravity` — whatever order the file wrote
|
|
69
|
+
* them in, so what a render records of it does not depend on the spelling.
|
|
70
|
+
*/
|
|
71
|
+
export function readEnvironmentSchedule(text: string, where: string): EnvironmentStep[] {
|
|
72
|
+
const refuse = (what: string): EnvironmentError => new EnvironmentError(`--environment ${where}: ${what}`);
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(text);
|
|
76
|
+
} catch (err) {
|
|
77
|
+
throw refuse(`not JSON — ${(err as Error).message}`);
|
|
78
|
+
}
|
|
79
|
+
const shape = `{ "spec": "${ENVIRONMENT_SPEC}", "steps": [{ "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }, …] }`;
|
|
80
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) throw refuse(`the file holds ${kindOf(parsed)}, not an object ${shape}`);
|
|
81
|
+
const top = parsed as Record<string, unknown>;
|
|
82
|
+
for (const key of Object.keys(top)) {
|
|
83
|
+
if (!(TOP_KEYS as readonly string[]).includes(key)) throw refuse(`the file names "${key}", which is not one of [${TOP_KEYS.join(', ')}]`);
|
|
84
|
+
}
|
|
85
|
+
if (top.spec !== ENVIRONMENT_SPEC) throw refuse(`"spec" is ${shown(top.spec)}; render reads "${ENVIRONMENT_SPEC}"`);
|
|
86
|
+
const steps = top.steps;
|
|
87
|
+
if (!Array.isArray(steps) || steps.length === 0) throw refuse(`"steps" is ${shown(steps)}, not a non-empty array of { "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }`);
|
|
88
|
+
const out: EnvironmentStep[] = [];
|
|
89
|
+
steps.forEach((entry: unknown, i) => {
|
|
90
|
+
const at = `entry ${i} of "steps"`;
|
|
91
|
+
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) throw refuse(`${at} is ${kindOf(entry)}, not an object { "t": <seconds>, "wind"?: [x, y], "gravity"?: [x, y] }`);
|
|
92
|
+
const given = entry as Record<string, unknown>;
|
|
93
|
+
for (const key of Object.keys(given)) {
|
|
94
|
+
if (!(STEP_KEYS as readonly string[]).includes(key)) throw refuse(`${at} names "${key}", which is not one of [${STEP_KEYS.join(', ')}]`);
|
|
95
|
+
}
|
|
96
|
+
const t = given.t;
|
|
97
|
+
if (typeof t !== 'number' || !Number.isFinite(t) || t < 0) throw refuse(`${at}'s "t" is ${shown(t)}, not a finite time in seconds at or above 0`);
|
|
98
|
+
const previous = out[i - 1];
|
|
99
|
+
if (previous !== undefined && !(t > previous.t)) {
|
|
100
|
+
throw refuse(`${at}'s "t" is ${shown(t)}, not after entry ${i - 1}'s ${shown(previous.t)} — steps are listed in ascending time, each holding until the next`);
|
|
101
|
+
}
|
|
102
|
+
const pair = (key: 'wind' | 'gravity'): [number, number] | undefined => {
|
|
103
|
+
const v = given[key];
|
|
104
|
+
if (v === undefined) return undefined;
|
|
105
|
+
if (!Array.isArray(v) || v.length !== 2 || v.some((n) => typeof n !== 'number' || !Number.isFinite(n))) throw refuse(`${at}'s "${key}" is ${shown(v)}, not ${PAIR}`);
|
|
106
|
+
return [v[0] as number, v[1] as number];
|
|
107
|
+
};
|
|
108
|
+
const wind = pair('wind');
|
|
109
|
+
const gravity = pair('gravity');
|
|
110
|
+
if (wind === undefined && gravity === undefined) throw refuse(`${at} (t ${shown(t)}) states neither "wind" nor "gravity", so it changes nothing`);
|
|
111
|
+
out.push({ t, ...(wind === undefined ? {} : { wind }), ...(gravity === undefined ? {} : { gravity }) });
|
|
112
|
+
});
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The frame a step at `t` seconds is first posed on at `fps`: the first frame
|
|
118
|
+
* `i` whose time `i / fps` is at or after `t`. Its change is set before the
|
|
119
|
+
* step that poses that frame, so the physics integrates the new world over the
|
|
120
|
+
* frame's whole interval. Exact for the predicate as written: `i / fps` is the
|
|
121
|
+
* division correctly rounded, so a `t` that names a frame's time lands on it.
|
|
122
|
+
*/
|
|
123
|
+
export function environmentFrame(t: number, fps: number): number {
|
|
124
|
+
let i = Math.max(0, Math.ceil(t * fps));
|
|
125
|
+
while (i > 0 && (i - 1) / fps >= t) i--;
|
|
126
|
+
while (i / fps < t) i++;
|
|
127
|
+
return i;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* A schedule on the frame grid of `fps`: each frame's change, or `undefined`.
|
|
132
|
+
* Steps landing on one frame are merged in order, a later half over an
|
|
133
|
+
* earlier — which only the framing meets (`FRAMING_FPS`), since `render`
|
|
134
|
+
* refuses a schedule two of whose steps share a frame of its own grid
|
|
135
|
+
* (`environmentCollisions`).
|
|
136
|
+
*/
|
|
137
|
+
export function environmentAt(steps: readonly EnvironmentStep[], fps: number): (index: number) => EnvironmentChange | undefined {
|
|
138
|
+
const byFrame = new Map<number, EnvironmentChange>();
|
|
139
|
+
for (const step of steps) {
|
|
140
|
+
const frame = environmentFrame(step.t, fps);
|
|
141
|
+
const before = byFrame.get(frame) ?? {};
|
|
142
|
+
byFrame.set(frame, { ...before, ...(step.wind === undefined ? {} : { wind: step.wind }), ...(step.gravity === undefined ? {} : { gravity: step.gravity }) });
|
|
143
|
+
}
|
|
144
|
+
return (index) => byFrame.get(index);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The steps that share a frame of the `fps` grid, by frame — each group's first step would never be posed. */
|
|
148
|
+
export function environmentCollisions(steps: readonly EnvironmentStep[], fps: number): Array<{ frame: number; entries: number[] }> {
|
|
149
|
+
const groups = new Map<number, number[]>();
|
|
150
|
+
steps.forEach((step, i) => {
|
|
151
|
+
const frame = environmentFrame(step.t, fps);
|
|
152
|
+
groups.set(frame, [...(groups.get(frame) ?? []), i]);
|
|
153
|
+
});
|
|
154
|
+
return [...groups].filter(([, entries]) => entries.length > 1).map(([frame, entries]) => ({ frame, entries }));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Which physics constraints of a Spine 4.3 skeleton answer each half of the
|
|
159
|
+
* world: `declared` every physics constraint by name; `wind` and `gravity` the
|
|
160
|
+
* ones stating a non-zero `wind` or `gravity` (the runtime reads an absent one
|
|
161
|
+
* as 0) and, as `"<constraint>" keyed by "<animation>"`, every physics
|
|
162
|
+
* timeline keying that field (`""` keys every constraint). Read off the
|
|
163
|
+
* skeleton's JSON as plain JSON, the same text both posers pose.
|
|
164
|
+
*/
|
|
165
|
+
export interface EnvironmentResponse {
|
|
166
|
+
declared: string[];
|
|
167
|
+
wind: string[];
|
|
168
|
+
gravity: string[];
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function objectOf(value: unknown): Record<string, unknown> {
|
|
172
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function environmentResponse(skeletonText: string): EnvironmentResponse {
|
|
176
|
+
const root = objectOf(JSON.parse(skeletonText));
|
|
177
|
+
const constraints = Array.isArray(root.constraints) ? root.constraints.map(objectOf).filter((c) => c.type === 'physics') : [];
|
|
178
|
+
const declared = constraints.map((c) => String(c.name));
|
|
179
|
+
const out: EnvironmentResponse = { declared, wind: [], gravity: [] };
|
|
180
|
+
for (const half of ['wind', 'gravity'] as const) {
|
|
181
|
+
for (const c of constraints) {
|
|
182
|
+
const v = c[half];
|
|
183
|
+
if (typeof v === 'number' && v !== 0) out[half].push(`"${String(c.name)}" (${half} ${v})`);
|
|
184
|
+
}
|
|
185
|
+
for (const [animation, body] of Object.entries(objectOf(root.animations))) {
|
|
186
|
+
for (const [target, timelines] of Object.entries(objectOf(objectOf(body).physics))) {
|
|
187
|
+
const keys = objectOf(timelines)[half];
|
|
188
|
+
if (Array.isArray(keys) && keys.length > 0) out[half].push(`${target === '' ? 'every constraint' : `"${target}"`} keyed by "${animation}"`);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return out;
|
|
193
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -130,6 +130,7 @@ import { dirname, join } from 'node:path';
|
|
|
130
130
|
import { Plate, readPlate } from '../tools/plate.ts';
|
|
131
131
|
import { clipSourceOf, corePoser, inactiveBoneSnapshot, physicsClockStep, subsetOver, unposedBones } from './render_core.ts';
|
|
132
132
|
import { type PosedVertices, type WorldTransform } from './nonfinite.ts';
|
|
133
|
+
import type { EnvironmentChange } from './environment.ts';
|
|
133
134
|
import {
|
|
134
135
|
atlasScales,
|
|
135
136
|
choosePosers,
|
|
@@ -500,7 +501,7 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
500
501
|
slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
501
502
|
subset: (opts, skin) => slotSubsetOf(data, opts, skin),
|
|
502
503
|
setup: (skin) => spinePosed(setupPosed(data, skin)),
|
|
503
|
-
animation: (name, skin, fps, count, visit) => {
|
|
504
|
+
animation: (name, skin, fps, count, visit, environment) => {
|
|
504
505
|
const skeleton = skeletonUnderSkin(data, skin);
|
|
505
506
|
const state = new AnimationState(new AnimationStateData(data));
|
|
506
507
|
// Not looping: the last frame sits at the animation's duration, and a looping
|
|
@@ -515,10 +516,12 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
515
516
|
if (i > 0) {
|
|
516
517
|
state.update(step);
|
|
517
518
|
state.apply(skeleton);
|
|
519
|
+
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
518
520
|
skeleton.update(clock);
|
|
519
521
|
skeleton.updateWorldTransform(Physics.update);
|
|
520
522
|
} else {
|
|
521
523
|
state.apply(skeleton);
|
|
524
|
+
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
522
525
|
skeleton.update(0);
|
|
523
526
|
skeleton.updateWorldTransform(Physics.reset);
|
|
524
527
|
}
|
|
@@ -529,6 +532,17 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
529
532
|
};
|
|
530
533
|
}
|
|
531
534
|
|
|
535
|
+
/**
|
|
536
|
+
* `render --environment`'s change for one frame (issue #1416), set on spine-core's own `Skeleton` before the update that
|
|
537
|
+
* poses the frame: `wind` is `windX, windY` and `gravity` is `gravityX, gravityY`, both in the runtime's convention, x
|
|
538
|
+
* right and y down — the live track's `setEnvironment` mapping, held to these four fields by the core suite's `CO56`. A
|
|
539
|
+
* half left out keeps the one standing, as the core merges it.
|
|
540
|
+
*/
|
|
541
|
+
function setSkeletonEnvironment(skeleton: Skeleton, change: EnvironmentChange | undefined): void {
|
|
542
|
+
if (change?.wind !== undefined) [skeleton.windX, skeleton.windY] = change.wind;
|
|
543
|
+
if (change?.gravity !== undefined) [skeleton.gravityX, skeleton.gravityY] = change.gravity;
|
|
544
|
+
}
|
|
545
|
+
|
|
532
546
|
/** A spine-core skeleton as it stands posed, read through the seam's `Posed`. */
|
|
533
547
|
function spinePosed(skeleton: Skeleton): Posed {
|
|
534
548
|
return {
|
package/src/render_core.ts
CHANGED
|
@@ -100,7 +100,7 @@ import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
|
|
|
100
100
|
import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
|
|
101
101
|
import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
|
|
102
102
|
import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
|
|
103
|
-
import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose } from './core/raw.ts';
|
|
103
|
+
import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose, type StepEnvironment } from './core/raw.ts';
|
|
104
104
|
import type { TimelinePlant } from './core/animation.ts';
|
|
105
105
|
import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
|
|
106
106
|
import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
|
|
@@ -363,10 +363,14 @@ export function keptPieceArrays(): PieceArrays {
|
|
|
363
363
|
* one pose is held at a time rather than the animation's whole series. A plant
|
|
364
364
|
* passes another (`RC44`).
|
|
365
365
|
*/
|
|
366
|
-
export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void) => void;
|
|
366
|
+
export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, environment?: StepEnvironment) => void;
|
|
367
367
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
368
|
+
/**
|
|
369
|
+
* The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next, the
|
|
370
|
+
* physics clock moved by `physicsClockStep` (issue #1380), and `render --environment`'s change for each pose set before it
|
|
371
|
+
* is posed (issue #1416, `StepEnvironment`).
|
|
372
|
+
*/
|
|
373
|
+
export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit, environment) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep, environment);
|
|
370
374
|
|
|
371
375
|
/**
|
|
372
376
|
* The render's physics clock (issue #1380): defined beside the walk that takes it (`./core/raw.ts`), where the mesh
|
|
@@ -750,7 +754,7 @@ export function corePoser(
|
|
|
750
754
|
const input = inputOf(skin);
|
|
751
755
|
return corePosed(input, poseRawSetup(input.doc, { through }), through);
|
|
752
756
|
},
|
|
753
|
-
animation: (name, skin, fps, count, visit) => {
|
|
757
|
+
animation: (name, skin, fps, count, visit, environment) => {
|
|
754
758
|
const input = inputOf(skin);
|
|
755
759
|
const step = 1 / fps;
|
|
756
760
|
// Each pose is drawn as it is posed and released before the next (issue #1180), so the walk holds one pose and not
|
|
@@ -765,7 +769,7 @@ export function corePoser(
|
|
|
765
769
|
} catch (thrown) {
|
|
766
770
|
drawFailed.push(thrown);
|
|
767
771
|
}
|
|
768
|
-
});
|
|
772
|
+
}, environment);
|
|
769
773
|
if (drawFailed.length > 0) throw drawFailed[0];
|
|
770
774
|
},
|
|
771
775
|
rest: (skin, shown) => {
|
package/src/render_shared.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
import { spinePosingFor, type SpineSkeletonData } from './spine_side.ts';
|
|
39
39
|
import { walkTimelines } from './timelines.ts';
|
|
40
40
|
import { firstNonFinite, type PosedVertices, type WorldTransform } from './nonfinite.ts';
|
|
41
|
+
import { environmentAt, type EnvironmentChange, type EnvironmentStep } from './environment.ts';
|
|
41
42
|
|
|
42
43
|
// ---------------------------------------------------------------------------
|
|
43
44
|
// the Spine side, through the seam (issue #1052)
|
|
@@ -211,6 +212,16 @@ export interface FramesSidecar {
|
|
|
211
212
|
slots?: string[];
|
|
212
213
|
/** The slots these frames leave out, when `render --hide` named them — see `slots`. */
|
|
213
214
|
hidden?: string[];
|
|
215
|
+
/**
|
|
216
|
+
* The schedule the physics answered, when `render --environment` gave one
|
|
217
|
+
* (issue #1416) — its steps as read, each `{ t, wind?, gravity? }`.
|
|
218
|
+
*
|
|
219
|
+
* ⭐ Absent on a render without it, for `skin`'s reason: every frame set
|
|
220
|
+
* written before the field existed says the same. A frame set carrying it is
|
|
221
|
+
* a picture of the rig in a world `check` does not pose, so `check` refuses
|
|
222
|
+
* it as a reference by name.
|
|
223
|
+
*/
|
|
224
|
+
environment?: EnvironmentStep[];
|
|
214
225
|
/** The colour the frames were cleared to, straight RGBA 0..255. */
|
|
215
226
|
background: RGBA;
|
|
216
227
|
viewport: {
|
|
@@ -391,6 +402,19 @@ export interface PoseOptions {
|
|
|
391
402
|
* second render writes.
|
|
392
403
|
*/
|
|
393
404
|
unclipped?: boolean;
|
|
405
|
+
/**
|
|
406
|
+
* The world the physics answers, in time — `render --environment` (issue
|
|
407
|
+
* #1416, `./environment.ts`): each step's change set before the step that
|
|
408
|
+
* poses the first frame at or after its `t`, on the sampler's own frame grid,
|
|
409
|
+
* on whichever poser draws. Absent, nothing is set and every pose is the one
|
|
410
|
+
* it always was.
|
|
411
|
+
*
|
|
412
|
+
* ⭐ **It is part of the shot, so the framing reads it.** Unlike `slots` and
|
|
413
|
+
* `hidden`, which say which pixels are drawn of one pose, an environment
|
|
414
|
+
* changes the pose — a gust can carry a coat past the box a still rig fills —
|
|
415
|
+
* so `framingViewport` keeps it and the box holds what the frames draw.
|
|
416
|
+
*/
|
|
417
|
+
environment?: readonly EnvironmentStep[];
|
|
394
418
|
}
|
|
395
419
|
|
|
396
420
|
/**
|
|
@@ -1025,8 +1049,10 @@ export interface Poser {
|
|
|
1025
1049
|
/**
|
|
1026
1050
|
* Animation `name` under `skin`, not looping: `visit(i, posed)` for every
|
|
1027
1051
|
* `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
|
|
1052
|
+
* `environment(i)`, when given, is the change to the world the physics
|
|
1053
|
+
* answers set before the step that poses frame `i` (issue #1416).
|
|
1028
1054
|
*/
|
|
1029
|
-
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void): void;
|
|
1055
|
+
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined): void;
|
|
1030
1056
|
/** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
|
|
1031
1057
|
rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
|
|
1032
1058
|
}
|
|
@@ -1091,7 +1117,12 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
|
|
|
1091
1117
|
const count = Math.round(animation.duration * fps);
|
|
1092
1118
|
const draw = drawResolver(poser, opts);
|
|
1093
1119
|
const frames: Frame[] = [];
|
|
1094
|
-
|
|
1120
|
+
const visit = (i: number, posed: Posed): void => {
|
|
1121
|
+
frames.push(frameOf(i, i * step, posed, draw(), opts));
|
|
1122
|
+
};
|
|
1123
|
+
// Without an environment the call is the one it always was, so a poser that takes none is asked nothing new.
|
|
1124
|
+
if (opts?.environment === undefined) poser.animation(name, opts?.skin, fps, count, visit);
|
|
1125
|
+
else poser.animation(name, opts.skin, fps, count, visit, environmentAt(opts.environment, fps));
|
|
1095
1126
|
return frames;
|
|
1096
1127
|
}
|
|
1097
1128
|
|
|
@@ -1411,6 +1442,8 @@ export interface GeometryFile {
|
|
|
1411
1442
|
/** The animation, or `null` for a skeleton with none (its one frame is the setup pose). */
|
|
1412
1443
|
animation: string | null;
|
|
1413
1444
|
skin?: string;
|
|
1445
|
+
/** The schedule the physics answered (`render --environment`, issue #1416) — `frames.json`'s own `environment`; absent without it. */
|
|
1446
|
+
environment?: EnvironmentStep[];
|
|
1414
1447
|
fps: number;
|
|
1415
1448
|
/** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
|
|
1416
1449
|
viewport: FramesSidecar['viewport'];
|
|
@@ -1470,6 +1503,7 @@ export function geometryFileOf(
|
|
|
1470
1503
|
frames: Frame[],
|
|
1471
1504
|
viewport: Viewport,
|
|
1472
1505
|
skin: string | undefined,
|
|
1506
|
+
environment?: EnvironmentStep[],
|
|
1473
1507
|
): GeometryFile {
|
|
1474
1508
|
const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
|
|
1475
1509
|
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
@@ -1493,6 +1527,7 @@ export function geometryFileOf(
|
|
|
1493
1527
|
coordinates: GEOMETRY_COORDINATES,
|
|
1494
1528
|
animation,
|
|
1495
1529
|
...(skin === undefined ? {} : { skin }),
|
|
1530
|
+
...(environment === undefined ? {} : { environment }),
|
|
1496
1531
|
fps,
|
|
1497
1532
|
viewport: sidecarViewport(viewport),
|
|
1498
1533
|
bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
|
package/src/timelines.ts
CHANGED
|
@@ -740,6 +740,67 @@ export const PHYSICS_POSE_RULES: PhysicsPoseRule[] = [
|
|
|
740
740
|
},
|
|
741
741
|
];
|
|
742
742
|
|
|
743
|
+
/** What `physicsStepStability` reads off one pose of a physics constraint. */
|
|
744
|
+
export interface PhysicsStepStability {
|
|
745
|
+
/** `strength · massInverse · step²`, as the step's own products form it: `strength * (step * massInverse) * step`. */
|
|
746
|
+
readonly k: number;
|
|
747
|
+
/** The per-step velocity decay, `damping ** (60 * step)` — the runtime's own expression. */
|
|
748
|
+
readonly d: number;
|
|
749
|
+
/** `2 · (1 + d)`: the step is stable exactly when `k` is below it. */
|
|
750
|
+
readonly bound: number;
|
|
751
|
+
/** The largest magnitude among the step map's two eigenvalues — what one step multiplies an undisturbed offset by, at most. */
|
|
752
|
+
readonly growth: number;
|
|
753
|
+
/** `k < bound`, strictly. */
|
|
754
|
+
readonly stable: boolean;
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/**
|
|
758
|
+
* Whether one pose of a physics constraint integrates to rest or runs away
|
|
759
|
+
* (issue #1416, part B) — a joint condition on three of the four values
|
|
760
|
+
* `PHYSICS_POSE_RULES` bounds one at a time, and on the constraint's `step`.
|
|
761
|
+
*
|
|
762
|
+
* 🔑 Every substep of `stepPhysics` (`src/core/constraints_physics.ts`, held to
|
|
763
|
+
* spine-core's `PhysicsConstraint.update` at tolerance 0) is, on each driven
|
|
764
|
+
* component, `v += (force − offset·strength)·m; offset += v·step; v *= d` with
|
|
765
|
+
* `m = step · massInverse` and `d = damping ** (60·step)`. The force is
|
|
766
|
+
* additive — wind and gravity set how far the offset goes, never whether it
|
|
767
|
+
* comes back — so with `u = v·step` the step is the linear map
|
|
768
|
+
* `[offset; u] ← [[1 − k, 1], [−k·d, d]]·[offset; u]`, `k = strength·m·step`:
|
|
769
|
+
* trace `1 − k + d`, determinant `d`. For `d` in `[0, 1]` (the damping row's own
|
|
770
|
+
* bound) both eigenvalues lie inside the unit circle exactly when
|
|
771
|
+
* `0 < k < 2·(1 + d)`. The lower end is the strength row's; this is the upper.
|
|
772
|
+
*
|
|
773
|
+
* ⚠️ **Strict.** At `k = 2·(1 + d)` one eigenvalue is exactly −1: the offset
|
|
774
|
+
* flips sign every step and never settles, and at `d = 1` the two coincide and
|
|
775
|
+
* it grows linearly.
|
|
776
|
+
*
|
|
777
|
+
* 📏 [measured, issue #1416's comment of 2026-10-14] on 270 generated rigs —
|
|
778
|
+
* x, y, rotate, scaleX and shearX drives; strength 10/100/1000; mass 0.1/1; fps
|
|
779
|
+
* 5/15/60; damping 0/0.85/1 — 115 of 115 with `k > bound` ran past 10⁶ units
|
|
780
|
+
* under a unit gust along the drive (92 of them in the runtime's default world
|
|
781
|
+
* with nothing set), 145 of 145 with `k < bound` stayed bounded, core and
|
|
782
|
+
* spine-core identical bit for bit. The 10 exactly on it (damping 1, k = 4)
|
|
783
|
+
* stayed under 10⁶ in the 6 s walk, which is why the 6 s classifier cannot
|
|
784
|
+
* stand for this edge: the x, y and scaleX ones ended the walk at their peak,
|
|
785
|
+
* still growing, and rotate and shearX swung their tip the bone's full length.
|
|
786
|
+
* All 270 passed every gate before this, because each value was inside its
|
|
787
|
+
* own row.
|
|
788
|
+
*
|
|
789
|
+
* The products are formed in the runtime's own order (`m = t * massInverse`,
|
|
790
|
+
* `d = damping ** (60 * t)`), so the number printed is the runtime's `k`
|
|
791
|
+
* rather than a re-association of it.
|
|
792
|
+
*/
|
|
793
|
+
export function physicsStepStability(strength: number, massInverse: number, damping: number, step: number): PhysicsStepStability {
|
|
794
|
+
const m = step * massInverse;
|
|
795
|
+
const k = strength * m * step;
|
|
796
|
+
const d = damping ** (60 * step);
|
|
797
|
+
const bound = 2 * (1 + d);
|
|
798
|
+
const trace = 1 - k + d;
|
|
799
|
+
const disc = trace * trace - 4 * d;
|
|
800
|
+
const growth = disc >= 0 ? Math.max(Math.abs(trace + Math.sqrt(disc)), Math.abs(trace - Math.sqrt(disc))) / 2 : Math.sqrt(d);
|
|
801
|
+
return { k, d, bound, growth, stable: k < bound };
|
|
802
|
+
}
|
|
803
|
+
|
|
743
804
|
/**
|
|
744
805
|
* What `A23`'s setup arm says about a pose value its rule refuses: the arm of
|
|
745
806
|
* `outside` that holds for it, or the bound itself where the row has no arms or
|