rig-c 3.2.0 → 3.3.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/docs/AUTHORING.md +80 -3
- package/package.json +1 -1
- package/src/boundary.ts +129 -0
- package/src/check.ts +3 -0
- package/src/cli/core_commands.ts +23 -7
- package/src/cli/shared.ts +5 -1
- package/src/render.ts +8 -7
- package/src/render_core.ts +27 -8
- package/src/render_shared.ts +64 -16
- package/src/sheet.ts +6 -0
package/docs/AUTHORING.md
CHANGED
|
@@ -130,6 +130,7 @@ commands take it and what its default is.
|
|
|
130
130
|
| `split --rig … --motion … --slot <name> --out <dir>` | the answer to a mesh refused because its triangles are **several pieces that share no vertex** — two legs after the triangles bridging them were deleted. It reads the two specs and writes them back into an absent or empty `--out` with one mesh per piece: piece 1 stays in the slot, piece k gets a new slot `<slot>_<k>` after it, each with the original's uvs and weights unchanged, its triangles renumbered outline first where the hull rule needs it, and every deform key re-sliced to the new indices — plus `split.json`, where every original vertex and triangle went. One piece is said so and nothing is written; what it cannot carry is refused by name ([AUTHORING §3.4.11](../docs/AUTHORING.md)) <a id="cmd-split"></a> |
|
|
131
131
|
| `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 <a id="cmd-explain"></a> |
|
|
132
132
|
| `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--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. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box; `ghostA` and `ghostB` partition the same ghost by which named pair member drew it, each with its own frames, spans and worst; `gapOpen` and `gapCovered` partition `gap` by zero versus positive rendered alpha from the whole skeleton, including faint and clipped drawings; ghost counts only visible contributions after draw order; `exposureA`/`exposureB` report each named slot’s whole-drawing background exposure even without a band ([AUTHORING §8.8](../docs/AUTHORING.md#88-where-two-slots-stop-meeting--render---seam)) — a reading, not a gate; the exit status never moves. `--overlaps` adds an `overlaps.json` per set and prints one `OVERLAP "<under>" under "<over>"` line per pair of slots where the one drawn later covers the earlier one and the motion moves it — the frames, and the most pixels covered and the frame of them — with an outline plate per covered frame; the render reports what was drawn, and the draw-order key is the author's remedy — a reading, not a gate. `--extremes` adds `extremes.json` and a native six-panel `extremes.png` (setup, geometry-pair min/max and three premultiplied RGBA diffs with a legend), plus one `EXTREMES` line per set. Per-channel minima/maxima and the farthest original-vertex geometry pair are distinct readings; valid channel extrema survive a geometry/image SKIP, with channel refusals reported separately; still or uncomparable geometry reports a named SKIP without changing render exit status. `--extremes-only` writes the same `extremes.json` and readable `extremes.png` bytes while skipping ordinary frame rasterization/encoding, `contact.png` and `frames.json`; it implies `--extremes` and takes no other diagnostic or sheet output flags. Look at min, max and diff before a person does ([AUTHORING §8.11](../docs/AUTHORING.md#811-min-max-and-diff-before-a-person-looks--render---extremes)). `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--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. `--preroll <n>` walks each set `n` whole loops on a looping track before the loop it records, so a looping idle is drawn with its physics already warm, as a player's looping track carries it ([AUTHORING §8.13](../docs/AUTHORING.md#813-a-loop-with-its-physics-warm--render---preroll)). `--sheet [--sheet-format json-hash] [--page-edges pot\|free]` also packs each set's straight-alpha transparent frames into `sheet.png` + `sheet.json` (TexturePacker JSON Hash with a shared world-origin `anchor` and `2`-px clamped edge extrusion, verified through `pixi.js@8.22.0` and `phaser@3.90.0` by `tools/sheet_readers.ts`) and `sheet-manifest.json` (`rigc-sheet/1`), leaving diagnostic `f####.png` and `contact.png` unchanged ([AUTHORING §8.10](../docs/AUTHORING.md#810-sprite-sheets-from-a-render--render---sheet)) — a self-contained sprite object per animation set; when animations play and how a scene is composed remains the consumer's <a id="cmd-render"></a> |
|
|
133
|
+
| `render … --physics off --boundary-from <dir>` | Suppress every physics constraint on both posers and read visible boundary displacement from a second render on the same viewport and frame grid. `frames.json` records off, `check` refuses it as a reference, and `boundary.json` carries per-frame distances or named SKIPs. See §8.14. |
|
|
133
134
|
| `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` <a id="cmd-preview"></a> |
|
|
134
135
|
| `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` <a id="cmd-vote"></a> |
|
|
135
136
|
| `pose --images <dir> --frame <png>` | reads part placements **out of** a picture <a id="cmd-pose"></a> |
|
|
@@ -389,7 +390,7 @@ What the flags mean:
|
|
|
389
390
|
| `--sheet-format` | `render --sheet` only: `json-hash` (default, TexturePacker JSON Hash with `frames`, `animations` and `meta`, verified against `pixi.js@8.22.0` and `phaser@3.90.0` by `tools/sheet_readers.ts`). Refused without `--sheet` or on an unknown value, exit 2, nothing written — **§8.10** |
|
|
390
391
|
| `--viewport`, `--viewport-from` | `render`: give the box every frame is drawn over instead of fitting one — `--viewport <x,y,w,h>`, a world box, y up, `x,y` its bottom-left corner, at the pixel size `--max` gives; or `--viewport-from <frames.json \| dir>`, an earlier render's grid exactly (its box, scale and pixel size), so two candidates land on one pixel grid. A slot that reaches past the box is named by set and frame, and `frames.json` records the box was given under `pinned`. The two together are refused, and so is `--max` beside `--viewport-from` — **§8.6**. On `check` and `inspect`, `--viewport` pins the box those commands draw over instead |
|
|
391
392
|
| `--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** |
|
|
392
|
-
| `--preroll` | `render` only: `<n>`, a whole number of 1 or more — walk each set `n` whole loops on a **looping** track before the loop recorded, drawn by no frame, the physics carried across every wrap as a player's looping track carries it, so a spring that converges is warm on the first frame. The walk lands on track time `n · duration` exactly, where the track has wrapped `n` times, so frame `i` is the pose at animation time `i / fps` — the time it is filed under — whether or not the duration is a whole number of frames (`PRE06`). Both posers walk it alike and write the same frames, `geometry.json`, sheet and `frames.json` (`PRE01`). `frames.json`, `geometry.json` and `sheet-manifest.json` record it as `preroll`, a sheet's namespace ends `/preroll=<n>`, and the framing reads it. A value that is not a whole number of 1 or more, a walk past what a step list holds, a skeleton with no animation or no physics constraint, a set of 0 frames at the rate, and `--environment` or `--parallax` beside it are refused by name, exit 2, nothing written; `check` refuses a frame set drawn after one as a reference. Without it every file is the bytes it always was — **§8.13** |
|
|
393
|
+
| `--preroll` | `render` only: `<n>`, a whole number of 1 or more — walk each set `n` whole loops on a **looping** track before the loop recorded, drawn by no frame, the physics carried across every wrap as a player's looping track carries it, so a spring that converges is warm on the first frame. The walk lands on track time `n · duration` exactly, where the track has wrapped `n` times, while its physics clock lands on the frame grid point nearest it, so the loop recorded is loop `n + 1` of a plain looping walk to rounding even where the duration is a float32 a hair off a whole number of frames (`PRE07`); so frame `i` is the pose at animation time `i / fps` — the time it is filed under — whether or not the duration is a whole number of frames (`PRE06`). Both posers walk it alike and write the same frames, `geometry.json`, sheet and `frames.json` (`PRE01`). `frames.json`, `geometry.json` and `sheet-manifest.json` record it as `preroll`, a sheet's namespace ends `/preroll=<n>`, and the framing reads it. A value that is not a whole number of 1 or more, a walk past what a step list holds, a skeleton with no animation or no physics constraint, a set of 0 frames at the rate, and `--environment` or `--parallax` beside it are refused by name, exit 2, nothing written; `check` refuses a frame set drawn after one as a reference. Without it every file is the bytes it always was — **§8.13** |
|
|
393
394
|
| `--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 |
|
|
394
395
|
| `--lever-fold` | `explain` only: a fraction above 0 and at most 1. Also read, on the turn lines' walk, the steps where a physics constraint that drives `scaleX` has its inertia lever divide by less than this fraction of the divisor it takes at the setup pose, and the steps where the scale factor it applies moves by more than the bone's setup scale. **No default**: without it the reading is not taken and one line says so; a value that is not such a fraction is refused by name (exit 2). A reading, not a gate — **§4.6.2** |
|
|
395
396
|
| `--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` |
|
|
@@ -9844,9 +9845,9 @@ first, drawn by no frame, then records the next loop:
|
|
|
9844
9845
|
- **The walk.** `count` is the set's frame count at the rate,
|
|
9845
9846
|
`round(duration · fps)`. From the same reset at frame 0 a render from rest
|
|
9846
9847
|
takes, the track steps the render's grid `j / fps` up to the last point before
|
|
9847
|
-
track time `n · duration`, takes one
|
|
9848
|
+
track time `n · duration`, takes one step onto it, then the `count`
|
|
9848
9849
|
steps of the loop recorded, `n · duration + i / fps` — on the render's own
|
|
9849
|
-
physics clock (§4.6) — and its animation time is the track time wrapped by the
|
|
9850
|
+
physics clock (§4.6), save the step onto the wrap (below) — and its animation time is the track time wrapped by the
|
|
9850
9851
|
duration, what spine-core's `setAnimation(0, name, true)` does and what the
|
|
9851
9852
|
core's live track does looping (`openTrack({ loop: true })`). Both posers add
|
|
9852
9853
|
the steps up the same way, and every one before the wrap subtracts without
|
|
@@ -9865,6 +9866,35 @@ first, drawn by no frame, then records the next loop:
|
|
|
9865
9866
|
float and the wrap is hit exactly; for a duration that is not, where the
|
|
9866
9867
|
product rounds below the wrap — the loop's end rather than its start — the
|
|
9867
9868
|
walk lands one float past it (`prerollStart`).
|
|
9869
|
+
- **The physics clock lands on the grid, not on the wrap** (issue #1687). The
|
|
9870
|
+
track's step onto the wrap is a frame only where the duration is exactly
|
|
9871
|
+
`count / fps`. A duration read as a float32 a hair below it — 8/3 s is read as
|
|
9872
|
+
2.6666665 s, `count / fps` less `δ` = 1.6e-7 s — makes that step a frame short
|
|
9873
|
+
by `n · δ`, far more than the render's clock lead has gathered by then (3.2e-7
|
|
9874
|
+
s against 5.0e-9 s at 2 loops at 12 fps); a physics constraint takes only the
|
|
9875
|
+
whole steps that fit, so the clock moved by it dropped one physics step at the
|
|
9876
|
+
landing, and the loop recorded started a fresh transient instead of continuing
|
|
9877
|
+
the settled cycle. So every step of the walk moves the physics clock by its own
|
|
9878
|
+
length except the step onto the wrap, which moves it to the grid point nearest
|
|
9879
|
+
the wrap: a whole frame where the wrap sits just short of one, nothing where it
|
|
9880
|
+
sits just past one and the step onto it is the sliver beyond. The loop recorded
|
|
9881
|
+
is then loop `n + 1` of a plain looping walk on the uniform grid, its physics
|
|
9882
|
+
clock off that walk's by rounding alone, and frame 0 is still the wrap — the
|
|
9883
|
+
keyed bones re-phased onto it by `n · δ`. Where the duration is exactly `count
|
|
9884
|
+
/ fps` the step onto the wrap is a frame and nothing moves; on a loop of no
|
|
9885
|
+
whole frame count the physics stays on the frame grid the same way and the
|
|
9886
|
+
keyed bones are re-phased by under half a frame. On the selftest's
|
|
9887
|
+
`preroll_probe` — four springs in a chain under loops read just short of 32
|
|
9888
|
+
and 80 frames and just past 48 — the short step moved the springs 26.4 units
|
|
9889
|
+
off the plain walk's loop on a stepped idle and 37.6 on an eased one at 2 loops
|
|
9890
|
+
and 12 fps; now, on the stepped loops at pre-rolls of 2 and 10 on both posers,
|
|
9891
|
+
every keyed bone is the plain walk's to the bit and every spring is within
|
|
9892
|
+
what one more clock lead moves it (`PRE07`, at most 2.2e-5 of it); restoring
|
|
9893
|
+
the short step on the core is red there on every case, by name (`PRE09`). On
|
|
9894
|
+
the eased loop the springs sit 5.3e-5 units off at 2 loops and 2.6e-4 at 10,
|
|
9895
|
+
linear in `n · δ`: that is the re-phase, which frame 0 being the wrap is. After
|
|
9896
|
+
10 loops every spring's seam closes within its largest step on both posers,
|
|
9897
|
+
where the short step opened four of five sets (`PRE08`).
|
|
9868
9898
|
- **What moves.** Only what carries from one step to the next: the physics. A
|
|
9869
9899
|
keyed bone is posed at the loop time the render from rest draws it at, to the
|
|
9870
9900
|
rounding of the steps' sums — on the skirt fixture's hip, measured on this walk
|
|
@@ -9921,6 +9951,53 @@ still refused above 0, would reach the same looping walk; counted in steps rathe
|
|
|
9921
9951
|
than loops, it would move every compared frame off its `FrameRef` time unless the
|
|
9922
9952
|
steps were whole loops.
|
|
9923
9953
|
|
|
9954
|
+
### 8.14 Physics-off renders and boundary comparisons
|
|
9955
|
+
|
|
9956
|
+
`rigc render --candidate <dir> --physics off --out <off>` suppresses every physics
|
|
9957
|
+
constraint on both posers, after the animation timelines apply. Every other
|
|
9958
|
+
constraint and the animation clock keep their ordinary walk. `--physics on` is the
|
|
9959
|
+
default. Any other value is refused by name. An omitted mode retains the existing
|
|
9960
|
+
render bytes. Off records `physicsMode: "off"` in `frames.json`, geometry and sheet
|
|
9961
|
+
metadata, and adds a `physics=off` variant to sheet frame keys. A reference
|
|
9962
|
+
without the field means on. `check` refuses an off reference because its candidate
|
|
9963
|
+
is posed with physics on.
|
|
9964
|
+
|
|
9965
|
+
To compare two whole-figure renders, first render the reference, then render the
|
|
9966
|
+
second with `--viewport-from <reference-dir> --boundary-from <reference-dir>`.
|
|
9967
|
+
For example, render the public gallery physics recipe normally, then render it
|
|
9968
|
+
with those two flags and `--physics off`. Use the same animation selection, skin,
|
|
9969
|
+
fps and pre-roll on both runs. The shared viewport fixes the world box, scale and
|
|
9970
|
+
pixel dimensions. Check the viewport's recorded `pinned.outside` when interpreting
|
|
9971
|
+
an outline that reaches the canvas edge.
|
|
9972
|
+
|
|
9973
|
+
`boundary.json` (`rigc-boundary-difference/1`) records each matching frame's maximum
|
|
9974
|
+
symmetric nearest-boundary distance in **L-infinity pixels**, and the maximum over
|
|
9975
|
+
the frames read. A boundary pixel is visible foreground with at least one
|
|
9976
|
+
four-neighbour background pixel, or a foreground pixel on the canvas edge. Holes
|
|
9977
|
+
contribute boundaries too. Foreground means the rendered RGBA differs from the
|
|
9978
|
+
recorded background exactly. Background-coloured art has no observable coverage
|
|
9979
|
+
in this reading, and anything outside the viewport is absent. This is a pixel
|
|
9980
|
+
reading with no registration, threshold or pass verdict.
|
|
9981
|
+
|
|
9982
|
+
An identical on/on pair reads zero. An on/off pair measures the visible outline
|
|
9983
|
+
change on the chosen grid. Empty boundaries report `SKIP`, never zero. A frame
|
|
9984
|
+
schedule, viewport, background, skin, set roster or file mismatch also reports
|
|
9985
|
+
`SKIP` with a reason. Missing or unreadable frames remain unavailable. Mixed
|
|
9986
|
+
readable and empty frames report `PARTIAL` with the per-frame reasons. Both sides
|
|
9987
|
+
must record the same slot selection. `scope` records that selection explicitly. A backdrop is part of the visible outline when it is drawn, so a
|
|
9988
|
+
full-canvas opaque backdrop can make the distance zero even while the character
|
|
9989
|
+
moves. For `gallery/look`, use `--hide plate` on both renders to read the character
|
|
9990
|
+
without its backdrop. Every other drawn slot still contributes to that outline.
|
|
9991
|
+
`--extremes-only` takes no `--boundary-from`, and the reference directory must differ
|
|
9992
|
+
from `--out`. Reusing an output directory clears its previous `boundary.json`
|
|
9993
|
+
before writing the new render, including an `--extremes-only` render.
|
|
9994
|
+
|
|
9995
|
+
`--extremes` chooses a pair within one animation and reads corresponding authored
|
|
9996
|
+
vertices and local channels. Subtracting two extreme readings does not compare
|
|
9997
|
+
the same frames' visible outlines. `diff` compares skeleton data, and `check`
|
|
9998
|
+
reposes and registers a candidate against reference pictures. Neither reads two
|
|
9999
|
+
already-rendered whole-figure boundaries on an unchanged grid.
|
|
10000
|
+
|
|
9924
10001
|
## 9. Checking against the frames — `rigc check`
|
|
9925
10002
|
|
|
9926
10003
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "rig-c",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.3.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": {
|
package/src/boundary.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Visible boundary displacement on the recorded pixel grid, without registration or thresholds. */
|
|
2
|
+
import { readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
|
|
5
|
+
import { FRAMES_SPEC, type FramesSidecar } from './render_shared.ts';
|
|
6
|
+
|
|
7
|
+
type DistanceReading = { status: 'READ'; maxPixels: number } | { status: 'SKIP'; reason: string };
|
|
8
|
+
|
|
9
|
+
/** Background-coloured pixels carry no observable foreground coverage. */
|
|
10
|
+
export function visibleBoundary(plate: Plate, background: RGBA): number[] {
|
|
11
|
+
const { width, height, data } = plate;
|
|
12
|
+
const foreground = new Uint8Array(width * height);
|
|
13
|
+
for (let pixel = 0; pixel < foreground.length; pixel++) {
|
|
14
|
+
foreground[pixel] = background.some((value, channel) => data[pixel * 4 + channel] !== value) ? 1 : 0;
|
|
15
|
+
}
|
|
16
|
+
const boundary: number[] = [];
|
|
17
|
+
for (let y = 0; y < height; y++) {
|
|
18
|
+
for (let x = 0; x < width; x++) {
|
|
19
|
+
const pixel = y * width + x;
|
|
20
|
+
if (foreground[pixel] && (
|
|
21
|
+
x === 0 || y === 0 || x === width - 1 || y === height - 1 ||
|
|
22
|
+
!foreground[pixel - 1] || !foreground[pixel + 1] ||
|
|
23
|
+
!foreground[pixel - width] || !foreground[pixel + width]
|
|
24
|
+
)) boundary.push(pixel);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return boundary;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Exact eight-neighbour nearest-boundary distance in L-infinity pixels. Linear in canvas area. */
|
|
31
|
+
function distances(width: number, height: number, boundary: readonly number[]): Int32Array {
|
|
32
|
+
const distance = new Int32Array(width * height).fill(-1);
|
|
33
|
+
const queue = new Int32Array(distance.length);
|
|
34
|
+
let end = 0;
|
|
35
|
+
for (const pixel of boundary) {
|
|
36
|
+
distance[pixel] = 0;
|
|
37
|
+
queue[end++] = pixel;
|
|
38
|
+
}
|
|
39
|
+
for (let head = 0; head < end; head++) {
|
|
40
|
+
const pixel = queue[head];
|
|
41
|
+
const x = pixel % width;
|
|
42
|
+
const y = Math.floor(pixel / width);
|
|
43
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
44
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
45
|
+
const nextX = x + dx, nextY = y + dy;
|
|
46
|
+
if (nextX < 0 || nextY < 0 || nextX >= width || nextY >= height) continue;
|
|
47
|
+
const next = nextY * width + nextX;
|
|
48
|
+
if (distance[next] !== -1) continue;
|
|
49
|
+
distance[next] = distance[pixel] + 1;
|
|
50
|
+
queue[end++] = next;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return distance;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function boundaryDistance(a: Plate, b: Plate, background: RGBA): DistanceReading {
|
|
58
|
+
if (a.width !== b.width || a.height !== b.height) return { status: 'SKIP', reason: 'pixel dimensions differ' };
|
|
59
|
+
const left = visibleBoundary(a, background), right = visibleBoundary(b, background);
|
|
60
|
+
if (left.length === 0 || right.length === 0) {
|
|
61
|
+
return { status: 'SKIP', reason: 'one or both frames have no visible foreground boundary' };
|
|
62
|
+
}
|
|
63
|
+
const toLeft = distances(a.width, a.height, left), toRight = distances(a.width, a.height, right);
|
|
64
|
+
let maxPixels = 0;
|
|
65
|
+
for (const pixel of left) maxPixels = Math.max(maxPixels, toRight[pixel]);
|
|
66
|
+
for (const pixel of right) maxPixels = Math.max(maxPixels, toLeft[pixel]);
|
|
67
|
+
return { status: 'READ', maxPixels };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SCHEDULE_FIELDS = ['animation', 'fps', 'sampled', 'written', 'stride', 'duration'] as const;
|
|
71
|
+
|
|
72
|
+
/** A failed observation remains a named SKIP and never becomes a zero distance. */
|
|
73
|
+
export function boundaryComparison(reference: string, current: string, sidecar: FramesSidecar) {
|
|
74
|
+
const spec = 'rigc-boundary-difference/1';
|
|
75
|
+
const skip = (reason: string) => ({ spec, status: 'SKIP' as const, reason });
|
|
76
|
+
try {
|
|
77
|
+
if (realpathSync(reference) === realpathSync(current)) return skip('reference and output directories are the same');
|
|
78
|
+
const before = JSON.parse(readFileSync(join(reference, 'frames.json'), 'utf8')) as FramesSidecar;
|
|
79
|
+
if (before.spec !== FRAMES_SPEC || !Array.isArray(before.sets)) return skip('reference requires a render frames.json');
|
|
80
|
+
const coordinates = Object.keys(sidecar.viewport) as Array<keyof typeof sidecar.viewport>;
|
|
81
|
+
if (!before.viewport || coordinates.some((key) => !Number.isFinite(before.viewport[key]) || before.viewport[key] !== sidecar.viewport[key])) {
|
|
82
|
+
return skip('world viewport or pixel grid differs, use --viewport-from');
|
|
83
|
+
}
|
|
84
|
+
if (JSON.stringify(before.background) !== JSON.stringify(sidecar.background)) return skip('recorded backgrounds differ');
|
|
85
|
+
if (before.skin !== sidecar.skin || JSON.stringify(before.slots) !== JSON.stringify(sidecar.slots) || JSON.stringify(before.hidden) !== JSON.stringify(sidecar.hidden)) {
|
|
86
|
+
return skip(`skin or drawn slot selection differs: reference ${JSON.stringify({ skin: before.skin ?? null, slots: before.slots ?? null, hidden: before.hidden ?? null })}, current ${JSON.stringify({ skin: sidecar.skin ?? null, slots: sidecar.slots ?? null, hidden: sidecar.hidden ?? null })}. Required equal recorded selections`);
|
|
87
|
+
}
|
|
88
|
+
const frames: Array<{ set: string; file: string } & DistanceReading> = [];
|
|
89
|
+
for (const set of sidecar.sets) {
|
|
90
|
+
const match = before.sets.find((other) => other.dir === set.dir);
|
|
91
|
+
if (!match || SCHEDULE_FIELDS.some((key) => match[key] !== set[key])) {
|
|
92
|
+
return skip(`set ${JSON.stringify(set.dir)} has a different frame schedule`);
|
|
93
|
+
}
|
|
94
|
+
if (!set.dir || set.dir === '..' || set.dir === '.' || set.dir.includes('/') || set.dir.includes('\\')) {
|
|
95
|
+
return skip('set directory must be one relative directory name');
|
|
96
|
+
}
|
|
97
|
+
const names = (root: string): string[] => readdirSync(join(root, set.dir)).filter((name) => /^f\d+\.png$/.test(name)).sort();
|
|
98
|
+
const left = names(reference), right = names(current);
|
|
99
|
+
if (left.length === 0 || left.length !== set.written || JSON.stringify(left) !== JSON.stringify(right)) {
|
|
100
|
+
return skip(`set ${JSON.stringify(set.dir)} has missing or different frame files`);
|
|
101
|
+
}
|
|
102
|
+
for (const file of left) {
|
|
103
|
+
const a = readPlate(join(reference, set.dir, file)), b = readPlate(join(current, set.dir, file));
|
|
104
|
+
if ([a, b].some((plate) => plate.width !== sidecar.viewport.pixelWidth || plate.height !== sidecar.viewport.pixelHeight)) {
|
|
105
|
+
return skip(`set ${JSON.stringify(set.dir)} frame ${file} pixel dimensions disagree with frames.json`);
|
|
106
|
+
}
|
|
107
|
+
frames.push({ set: set.dir, file, ...boundaryDistance(a, b, sidecar.background) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (before.sets.length !== sidecar.sets.length || frames.length === 0) return skip('set roster differs or no frames were compared');
|
|
111
|
+
const readings = frames.filter((frame) => frame.status === 'READ');
|
|
112
|
+
let maxPixels = 0;
|
|
113
|
+
for (const frame of readings) if (frame.status === 'READ') maxPixels = Math.max(maxPixels, frame.maxPixels);
|
|
114
|
+
return {
|
|
115
|
+
spec,
|
|
116
|
+
status: readings.length === frames.length ? 'READ' : readings.length ? 'PARTIAL' : 'SKIP',
|
|
117
|
+
metric: 'symmetric Hausdorff L-infinity pixels',
|
|
118
|
+
coverage: 'visible pixels differing from the recorded background. Four-neighbour boundary including holes, clipped to viewport',
|
|
119
|
+
referencePhysics: before.physicsMode ?? 'on',
|
|
120
|
+
physics: sidecar.physicsMode ?? 'on',
|
|
121
|
+
scope: { slots: sidecar.slots ?? null, hidden: sidecar.hidden ?? null },
|
|
122
|
+
viewport: sidecar.viewport,
|
|
123
|
+
maxPixels: readings.length ? maxPixels : null,
|
|
124
|
+
frames,
|
|
125
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return skip(`boundary unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/check.ts
CHANGED
|
@@ -1298,6 +1298,9 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
1298
1298
|
'difference between two worlds. Render the reference frames without --environment.',
|
|
1299
1299
|
);
|
|
1300
1300
|
}
|
|
1301
|
+
if (located.sidecar?.physicsMode === 'off') {
|
|
1302
|
+
throw new CheckError('frames.json records physicsMode off, while check poses the candidate with physics on. Render the reference with --physics on.');
|
|
1303
|
+
}
|
|
1301
1304
|
// A frame set drawn after a pre-roll (`render --preroll`, issue #1684) shows the physics warm; check poses the
|
|
1302
1305
|
// candidate from rest, so the score would be a number about the difference between a warm loop and a cold one.
|
|
1303
1306
|
const referencePreroll = located.sidecar?.preroll;
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { boundaryComparison } from '../boundary.ts';
|
|
1
2
|
import { sampleParallax, parallaxPlate } from '../parallax_render.ts';
|
|
2
3
|
import { textureScaleReading, textureScaleLines, TEXTURE_SCALE_FPS } from '../texture_scale.ts';
|
|
3
4
|
import { extremesOf, extremesText, extremesPlate, extremesLine, extremesRefusal } from '../extremes.ts';
|
|
@@ -723,6 +724,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
723
724
|
// The pre-roll (issue #1684), read and refused as early, and refused beside a schedule: under a pre-roll a schedule's
|
|
724
725
|
// `t` could be read on the recording's clock or on the walk's, the two give different physics, and nothing settles it.
|
|
725
726
|
const preroll = readPrerollFlag(flags);
|
|
727
|
+
if (flags.physics !== undefined && flags.physics !== 'on' && flags.physics !== 'off') throw new UsageError(`--physics ${JSON.stringify(flags.physics)}: required on or off`);
|
|
728
|
+
const physicsMode = flags.physics === 'off' ? 'off' as const : undefined;
|
|
726
729
|
if (preroll !== undefined && environment !== undefined) {
|
|
727
730
|
throw new UsageError(
|
|
728
731
|
`--preroll takes no --environment: a schedule's t could be read from the first frame recorded or from the first loop walked, ` +
|
|
@@ -734,7 +737,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
734
737
|
const given = readGivenViewport(flags);
|
|
735
738
|
const extremesOnly = flags['extremes-only'] !== undefined;
|
|
736
739
|
if (extremesOnly) {
|
|
737
|
-
for (const flag of ['sheet', 'sheet-format', 'page-edges', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range']) {
|
|
740
|
+
for (const flag of ['sheet', 'sheet-format', 'page-edges', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range', 'boundary-from']) {
|
|
738
741
|
if (flags[flag] !== undefined) {
|
|
739
742
|
throw new UsageError(`--extremes-only takes no --${flag}: it writes only extremes.json and, when readable, extremes.png. Drop --${flag}, or use --extremes for ordinary frames and additional outputs`);
|
|
740
743
|
}
|
|
@@ -753,6 +756,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
753
756
|
const sheetFormat = wantsSheet ? readSheetFormat(flags) : undefined;
|
|
754
757
|
const sheetPageEdges = wantsSheet ? readPageEdges(flags) : undefined;
|
|
755
758
|
const outRoot = resolve(flags.out ?? 'render');
|
|
759
|
+
if (flags['boundary-from'] !== undefined && realpathAhead(flags['boundary-from']) === realpathAhead(outRoot)) throw new UsageError('--boundary-from must name an earlier render directory different from --out');
|
|
756
760
|
const geometry = flags.geometry !== undefined;
|
|
757
761
|
const extremes = extremesOnly || flags.extremes !== undefined;
|
|
758
762
|
// The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
|
|
@@ -850,7 +854,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
850
854
|
posersAsked(flags),
|
|
851
855
|
);
|
|
852
856
|
if (flags.parallax !== undefined) {
|
|
853
|
-
for (const flag of ['animation', 'fps', 'slot', 'hide', 'geometry', 'folds', 'extremes', 'extremes-only', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'viewport', 'viewport-from']) {
|
|
857
|
+
for (const flag of ['animation', 'fps', 'slot', 'hide', 'geometry', 'folds', 'extremes', 'extremes-only', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'physics', 'boundary-from', 'viewport', 'viewport-from']) {
|
|
854
858
|
if (flags[flag] !== undefined) throw new UsageError(`--parallax takes no --${flag}: it samples one named dial on its own 9-value grid; drop --${flag} or run ordinary render separately`);
|
|
855
859
|
}
|
|
856
860
|
if (flags.poser !== undefined && flags.poser !== 'core') throw new UsageError('--parallax requires --poser core: it reads only a rigc compiled model');
|
|
@@ -898,13 +902,14 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
898
902
|
// what the frames under it draw.
|
|
899
903
|
// The pre-roll rides on it for the environment's reason (issue #1684): it changes the pose, so the framing reads it.
|
|
900
904
|
const pose =
|
|
901
|
-
skin === undefined && subset === undefined && environment === undefined && preroll === undefined
|
|
905
|
+
skin === undefined && subset === undefined && environment === undefined && preroll === undefined && physicsMode === undefined
|
|
902
906
|
? undefined
|
|
903
907
|
: {
|
|
904
908
|
...(skin === undefined ? {} : { skin }),
|
|
905
909
|
...subsetFields(subset),
|
|
906
910
|
...(environment === undefined ? {} : { environment: environment.steps }),
|
|
907
911
|
...(preroll === undefined ? {} : { preroll }),
|
|
912
|
+
...(physicsMode === undefined ? {} : { physicsMode }),
|
|
908
913
|
};
|
|
909
914
|
if (skin !== undefined) console.log(` .. skin ${skin}`);
|
|
910
915
|
if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
|
|
@@ -1019,7 +1024,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1019
1024
|
if (geometry) {
|
|
1020
1025
|
for (const [name, frames] of sampled) {
|
|
1021
1026
|
const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
|
|
1022
|
-
geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps, preroll)));
|
|
1027
|
+
geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps, preroll, physicsMode)));
|
|
1023
1028
|
}
|
|
1024
1029
|
}
|
|
1025
1030
|
// The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
|
|
@@ -1151,6 +1156,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1151
1156
|
subset,
|
|
1152
1157
|
environment: environment?.steps,
|
|
1153
1158
|
preroll,
|
|
1159
|
+
physicsMode,
|
|
1154
1160
|
pinned,
|
|
1155
1161
|
}),
|
|
1156
1162
|
);
|
|
@@ -1194,6 +1200,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1194
1200
|
}
|
|
1195
1201
|
|
|
1196
1202
|
mkdirSync(outRoot, { recursive: true });
|
|
1203
|
+
// A prior observation cannot describe the new frames, including an observation-only render.
|
|
1204
|
+
rmSync(join(outRoot, 'boundary.json'), { force: true });
|
|
1197
1205
|
if (extremesOnly) {
|
|
1198
1206
|
// Reuse the same posed grid and plates; selecting extremes needs no ordinary pixels.
|
|
1199
1207
|
// Clear only the selected sets, just as the ordinary render does. No frames.json
|
|
@@ -1289,7 +1297,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1289
1297
|
: ` + ${sheetPack.pages.map((p) => `${p.image} + ${p.table}`).join(' + ')} + ${SHEET_MANIFEST_FILE} (${sheetPack.pages.length} page(s), ${sheetPack.pageEdges})`;
|
|
1290
1298
|
const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${sheetNote}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}${overlapNote}`;
|
|
1291
1299
|
console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
|
|
1292
|
-
if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length)}`);
|
|
1300
|
+
if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length, physicsMode)}`);
|
|
1293
1301
|
for (const mesh of foldReading?.meshes ?? []) {
|
|
1294
1302
|
const key = `${mesh.slot}\u0000${mesh.attachment}`;
|
|
1295
1303
|
const fault = faultsSaid.has(key) ? null : setupFaultLine(mesh);
|
|
@@ -1330,6 +1338,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1330
1338
|
...(environment === undefined ? {} : { environment: environment.steps }),
|
|
1331
1339
|
// Written only when a pre-roll was asked for (issue #1684), for the same reason.
|
|
1332
1340
|
...(preroll === undefined ? {} : { preroll }),
|
|
1341
|
+
...(physicsMode === undefined ? {} : { physicsMode }),
|
|
1333
1342
|
// Written only when the box was given (issue #1414): a fitted render says nothing and stays the bytes it was.
|
|
1334
1343
|
...(given === undefined ? {} : { pinned: pinnedRecord(given.by, outside) }),
|
|
1335
1344
|
background: BACKGROUND,
|
|
@@ -1340,6 +1349,13 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1340
1349
|
};
|
|
1341
1350
|
writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
|
|
1342
1351
|
console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
|
|
1352
|
+
if (flags['boundary-from'] !== undefined) {
|
|
1353
|
+
const reading = boundaryComparison(resolve(flags['boundary-from']), outRoot, sidecar);
|
|
1354
|
+
writeFileSync(join(outRoot, 'boundary.json'), `${JSON.stringify(reading, null, 2)}\n`);
|
|
1355
|
+
const detail = 'reason' in reading ? reading.reason : `max ${String(reading.maxPixels)} L-infinity px over ${reading.frames.length} frame(s)`;
|
|
1356
|
+
console.log(` .. boundary ${reading.status}: ${detail}`);
|
|
1357
|
+
console.log(`rigc: wrote ${join(outRoot, 'boundary.json')}`);
|
|
1358
|
+
}
|
|
1343
1359
|
}
|
|
1344
1360
|
|
|
1345
1361
|
/**
|
|
@@ -1371,11 +1387,11 @@ export function physicsSteppedOf(frames: readonly Frame[], fps: number): { stepp
|
|
|
1371
1387
|
* `render`'s `physics:` line for one set (issue #1467): how many constraints were stepped at the set's rate, and — when
|
|
1372
1388
|
* fewer than the rig declares — the ones that took no step, by name, with why a constraint takes none.
|
|
1373
1389
|
*/
|
|
1374
|
-
export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number): string {
|
|
1390
|
+
export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number, physicsMode?: 'off'): string {
|
|
1375
1391
|
const head = `physics: ${stepped.length} constraint(s) stepped at ${fps} fps`;
|
|
1376
1392
|
const idle = declared.filter((name) => !stepped.includes(name));
|
|
1377
1393
|
if (idle.length === 0) return head;
|
|
1378
|
-
const why = frames === 1 ? 'a single pose takes no step' : 'inactive, or a mix of 0, on every frame';
|
|
1394
|
+
const why = physicsMode === 'off' ? '--physics off suppresses every physics constraint' : frames === 1 ? 'a single pose takes no step' : 'inactive, or a mix of 0, on every frame';
|
|
1379
1395
|
return `${head} — ${idle.length} of the ${declared.length} declared took none: ${idle.map((n) => JSON.stringify(n)).join(', ')} (${why})`;
|
|
1380
1396
|
}
|
|
1381
1397
|
|
package/src/cli/shared.ts
CHANGED
|
@@ -1108,6 +1108,8 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1108
1108
|
'runtime\'s, wind [1, 0] and gravity [0, 1]. frames.json and geometry.json record it, and the framing reads it. A ' +
|
|
1109
1109
|
'malformed file, a half no physics constraint answers, two steps on one frame and a step after every set\'s last frame ' +
|
|
1110
1110
|
'are refused by name. An inspection of what the rig offers, not a scene',
|
|
1111
|
+
physics: 'pose with physics on (default) or off. Off suppresses every physics constraint after timeline application on both posers. frames.json records physicsMode off and check refuses that reference',
|
|
1112
|
+
'boundary-from': 'read visible whole-figure pixel-boundary displacement against an earlier render on the identical viewport and frame grid. Writes boundary.json, no threshold or pass verdict',
|
|
1111
1113
|
preroll:
|
|
1112
1114
|
'walk each set this many whole loops on a looping track before the loop recorded, drawn by no frame, the physics carried ' +
|
|
1113
1115
|
'across every wrap as a player\'s looping track carries it — so a spring that converges is warm on the first frame. Frame i is the ' +
|
|
@@ -1307,6 +1309,8 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1307
1309
|
'texture-from': '<path>',
|
|
1308
1310
|
poser: 'core|spine',
|
|
1309
1311
|
environment: '<schedule.json>',
|
|
1312
|
+
physics: '<on|off>',
|
|
1313
|
+
'boundary-from': '<dir>',
|
|
1310
1314
|
preroll: '<n>',
|
|
1311
1315
|
'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
|
|
1312
1316
|
seam: '<slot>:<slot>[,<slot>:<slot>…]',
|
|
@@ -1785,7 +1789,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1785
1789
|
'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
|
|
1786
1790
|
'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
|
|
1787
1791
|
],
|
|
1788
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'extremes-only', 'parallax', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'poser', 'out'],
|
|
1792
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'extremes-only', 'parallax', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'physics', 'boundary-from', 'poser', 'out'],
|
|
1789
1793
|
overrides: {
|
|
1790
1794
|
'page-edges': {
|
|
1791
1795
|
meaning:
|
package/src/render.ts
CHANGED
|
@@ -502,8 +502,8 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
502
502
|
bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent?.name ?? null })),
|
|
503
503
|
slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
504
504
|
subset: (opts, skin) => slotSubsetOf(data, opts, skin),
|
|
505
|
-
setup: (skin) => spinePosed(setupPosed(data, skin)),
|
|
506
|
-
animation: (name, skin, fps, count, visit, environment, _channels, times, loop) => {
|
|
505
|
+
setup: (skin, _channels, physicsMode) => spinePosed(setupPosed(data, skin, physicsMode)),
|
|
506
|
+
animation: (name, skin, fps, count, visit, environment, _channels, times, loop, clockSteps, physicsMode) => {
|
|
507
507
|
const skeleton = skeletonUnderSkin(data, skin);
|
|
508
508
|
const state = new AnimationState(new AnimationStateData(data));
|
|
509
509
|
// Not looping: the last frame sits at the animation's duration, and a looping
|
|
@@ -522,13 +522,14 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
522
522
|
state.update(delta);
|
|
523
523
|
state.apply(skeleton);
|
|
524
524
|
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
525
|
-
|
|
526
|
-
skeleton.
|
|
525
|
+
// `clockSteps` (issue #1687, `render --preroll`): what the step moves the physics clock by, where that is not its own length.
|
|
526
|
+
skeleton.update(times === undefined ? clock : physicsClockStep(clockSteps?.[i - 1] ?? delta));
|
|
527
|
+
skeleton.updateWorldTransform(physicsMode === 'off' ? Physics.none : Physics.update);
|
|
527
528
|
} else {
|
|
528
529
|
state.apply(skeleton);
|
|
529
530
|
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
530
531
|
skeleton.update(0);
|
|
531
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
532
|
+
skeleton.updateWorldTransform(physicsMode === 'off' ? Physics.none : Physics.reset);
|
|
532
533
|
}
|
|
533
534
|
visit(i, posed);
|
|
534
535
|
}
|
|
@@ -591,11 +592,11 @@ function steppedPhysicsOf(skeleton: Skeleton): string[] {
|
|
|
591
592
|
* `sampleSetupPose` and the geometry export's `rest` table both pose from, so
|
|
592
593
|
* the two cannot come to describe different rest poses.
|
|
593
594
|
*/
|
|
594
|
-
function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
|
|
595
|
+
function setupPosed(data: SkeletonData, skin: string | undefined, physicsMode?: 'off'): Skeleton {
|
|
595
596
|
const skeleton = skeletonUnderSkin(data, skin);
|
|
596
597
|
skeleton.setupPose();
|
|
597
598
|
skeleton.update(0);
|
|
598
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
599
|
+
skeleton.updateWorldTransform(physicsMode === 'off' ? Physics.none : Physics.reset);
|
|
599
600
|
return skeleton;
|
|
600
601
|
}
|
|
601
602
|
|
package/src/render_core.ts
CHANGED
|
@@ -367,18 +367,35 @@ export function keptPieceArrays(): PieceArrays {
|
|
|
367
367
|
* one pose is held at a time rather than the animation's whole series. A plant
|
|
368
368
|
* passes another (`RC44`).
|
|
369
369
|
*/
|
|
370
|
-
export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, environment?: StepEnvironment, loop?: boolean) => void;
|
|
370
|
+
export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, environment?: StepEnvironment, loop?: boolean, clock?: (dt: number) => number) => void;
|
|
371
371
|
|
|
372
372
|
/**
|
|
373
373
|
* The walk `corePoser` steps with unless a plant passes another: one pose posed, drawn and released before the next, the
|
|
374
374
|
* physics clock moved by `physicsClockStep` (issue #1380), and `render --environment`'s change for each pose set before it
|
|
375
375
|
* is posed (issue #1416, `StepEnvironment`). `loop` (issue #1684, `render --preroll`) walks the track looping
|
|
376
376
|
* (`poseRawLoopEach`) — spine-core's poser sets the same track looping — and absent, the walk is the one it always was.
|
|
377
|
+
* `clock` (issue #1687) is what each step moves the physics clock by in place of `physicsClockStep` of the step — the
|
|
378
|
+
* pre-roll's walk passes one that reads its own step list (`Poser.animation`'s `clockSteps`) — and absent, the clock is
|
|
379
|
+
* the render's, `physicsClockStep`.
|
|
377
380
|
*/
|
|
378
|
-
export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit, environment, loop) =>
|
|
381
|
+
export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit, environment, loop, clock = physicsClockStep) =>
|
|
379
382
|
loop === true
|
|
380
|
-
? poseRawLoopEach(doc, animation, steps, plant, 'animation', visit,
|
|
381
|
-
: poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit,
|
|
383
|
+
? poseRawLoopEach(doc, animation, steps, plant, 'animation', visit, clock, environment)
|
|
384
|
+
: poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, clock, environment);
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The physics clock a walk moves by `clockSteps` (issue #1687, `Poser.animation`): the walk asks its clock once for
|
|
388
|
+
* the reset pose — a step of 0, which moves it by `physicsClockStep(0)`, nothing, as spine-core's `update(0)` at frame
|
|
389
|
+
* 0 does — and then once per step in order, and step `i` moves it by `physicsClockStep(clockSteps[i])` in place of
|
|
390
|
+
* `physicsClockStep` of the step's own length. One clock serves one walk: it counts the steps it was asked for.
|
|
391
|
+
*/
|
|
392
|
+
function steppedClock(clockSteps: readonly number[]): (dt: number) => number {
|
|
393
|
+
let next = -1;
|
|
394
|
+
return (dt) => {
|
|
395
|
+
const i = next++;
|
|
396
|
+
return physicsClockStep(i < 0 ? dt : clockSteps[i]);
|
|
397
|
+
};
|
|
398
|
+
}
|
|
382
399
|
|
|
383
400
|
/**
|
|
384
401
|
* The render's physics clock (issue #1380): defined beside the walk that takes it (`./core/raw.ts`), where the mesh
|
|
@@ -787,14 +804,14 @@ export function corePoser(
|
|
|
787
804
|
const result = poseRawDial(input.doc, override, name, { through });
|
|
788
805
|
return { posed: corePosed(input, result.pose, through, []), read: result.read, applied: result.applied };
|
|
789
806
|
},
|
|
790
|
-
setup: (skin, channels) => {
|
|
807
|
+
setup: (skin, channels, physicsMode) => {
|
|
791
808
|
const input = inputOf(skin);
|
|
792
809
|
let values: ChannelValue[] | undefined;
|
|
793
810
|
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
794
|
-
const pose = poseRawSetup(input.doc, { through, ...observe });
|
|
811
|
+
const pose = poseRawSetup(input.doc, { through, ...observe, ...(physicsMode === 'off' ? { physicsStep: () => false } : {}) });
|
|
795
812
|
return corePosed(input, pose, through, [], values);
|
|
796
813
|
},
|
|
797
|
-
animation: (name, skin, fps, count, visit, environment, channels, times, loop) => {
|
|
814
|
+
animation: (name, skin, fps, count, visit, environment, channels, times, loop, clockSteps, physicsMode) => {
|
|
798
815
|
let values: ChannelValue[] | undefined;
|
|
799
816
|
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
800
817
|
const input = inputOf(skin);
|
|
@@ -810,10 +827,12 @@ export function corePoser(
|
|
|
810
827
|
const steppedNow = new Set<string>();
|
|
811
828
|
const inner = plants.physicsStep ?? stepPhysics;
|
|
812
829
|
const physicsStep: PhysicsStepper = (r, w, length, ctx) => {
|
|
830
|
+
if (physicsMode === 'off') return false;
|
|
813
831
|
const moved = inner(r, w, length, ctx);
|
|
814
832
|
if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
|
|
815
833
|
return moved;
|
|
816
834
|
};
|
|
835
|
+
const clock = clockSteps === undefined ? undefined : steppedClock(clockSteps);
|
|
817
836
|
walk(input.doc, name, times === undefined ? new Array<number>(count).fill(step) : times.slice(1).map((t, i) => t - times[i]), { through, physicsStep, ...observe }, (pose, i) => {
|
|
818
837
|
const stepped = [...steppedNow].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
819
838
|
steppedNow.clear();
|
|
@@ -823,7 +842,7 @@ export function corePoser(
|
|
|
823
842
|
} catch (thrown) {
|
|
824
843
|
drawFailed.push(thrown);
|
|
825
844
|
}
|
|
826
|
-
}, environment, loop);
|
|
845
|
+
}, environment, loop, clock);
|
|
827
846
|
if (drawFailed.length > 0) throw drawFailed[0];
|
|
828
847
|
},
|
|
829
848
|
rest: (skin, shown) => {
|
package/src/render_shared.ts
CHANGED
|
@@ -243,6 +243,8 @@ export interface FrameSet {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
export interface FramesSidecar {
|
|
246
|
+
/** Absent means on, including frame sets written before this field existed. */
|
|
247
|
+
physicsMode?: 'off';
|
|
246
248
|
spec: string;
|
|
247
249
|
example?: string;
|
|
248
250
|
rung?: string;
|
|
@@ -427,6 +429,8 @@ export interface UvWindow {
|
|
|
427
429
|
|
|
428
430
|
/** Options for `piecesOf` and the samplers that call it. */
|
|
429
431
|
export interface PoseOptions {
|
|
432
|
+
/** Suppress every physics constraint after animation application. */
|
|
433
|
+
physicsMode?: 'off';
|
|
430
434
|
/** Also read local bone/deform channels and mapped slider times, and retain original attachment source identity outside JSON, for --extremes. */
|
|
431
435
|
channels?: boolean;
|
|
432
436
|
/** Also record each piece's original-art UVs — see `PieceTexture`. */
|
|
@@ -1184,7 +1188,7 @@ export interface Poser {
|
|
|
1184
1188
|
/** `slotSubsetOf` against this skeleton posed under `skin` — refused by `SlotSubsetError`. */
|
|
1185
1189
|
subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
|
|
1186
1190
|
/** The setup pose under `skin` (absent: no skin set at all). */
|
|
1187
|
-
setup(skin: string | undefined, channels?: boolean): Posed;
|
|
1191
|
+
setup(skin: string | undefined, channels?: boolean, physicsMode?: 'off'): Posed;
|
|
1188
1192
|
/**
|
|
1189
1193
|
* Animation `name` under `skin`, not looping: `visit(i, posed)` for every
|
|
1190
1194
|
* `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
|
|
@@ -1198,8 +1202,14 @@ export interface Poser {
|
|
|
1198
1202
|
* animation time the track time wrapped by the duration, the physics carried
|
|
1199
1203
|
* across every wrap, as a player's looping track carries it; absent, the
|
|
1200
1204
|
* track holds at its duration as it always did.
|
|
1205
|
+
* `clockSteps` (issue #1687, `render --preroll`), beside `times`, is what
|
|
1206
|
+
* each step moves the physics clock by before the lead: step `i` (from 1)
|
|
1207
|
+
* moves it by `physicsClockStep(clockSteps[i - 1])` in place of
|
|
1208
|
+
* `physicsClockStep(times[i] - times[i - 1])`, while the track time still
|
|
1209
|
+
* follows `times` (`prerollTimes` says why the two part). Absent, every
|
|
1210
|
+
* step moves the physics clock by its own length, as it always did.
|
|
1201
1211
|
*/
|
|
1202
|
-
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean, times?: readonly number[], loop?: boolean): void;
|
|
1212
|
+
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean, times?: readonly number[], loop?: boolean, clockSteps?: readonly number[], physicsMode?: 'off'): void;
|
|
1203
1213
|
/** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
|
|
1204
1214
|
rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
|
|
1205
1215
|
}
|
|
@@ -1280,18 +1290,19 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
|
|
|
1280
1290
|
if (!Number.isInteger(preroll) || preroll < 1) throw new Error(`preroll is ${String(preroll)}, not a whole number of loops at or above 1`);
|
|
1281
1291
|
if (opts?.environment !== undefined) throw new Error('preroll and environment were both given: nothing settles which clock the schedule is read on under a pre-roll');
|
|
1282
1292
|
// The track loops; every pose before track time `preroll · duration` is walked and drawn by no frame (issue #1684).
|
|
1283
|
-
const { times, first } = prerollTimes(animation.duration, fps, preroll);
|
|
1293
|
+
const { times, first, clock } = prerollTimes(animation.duration, fps, preroll);
|
|
1284
1294
|
const recorded = (i: number, posed: Posed): void => {
|
|
1285
1295
|
if (i >= first) frames.push(frameOf(i - first, (i - first) * step, posed, draw(), opts));
|
|
1286
1296
|
};
|
|
1287
|
-
poser.animation(name, opts?.skin, fps, times.length - 1, recorded, undefined, opts?.channels === true ? true : undefined, times, true);
|
|
1297
|
+
poser.animation(name, opts?.skin, fps, times.length - 1, recorded, undefined, opts?.channels === true ? true : undefined, times, true, clock, opts?.physicsMode);
|
|
1288
1298
|
return frames;
|
|
1289
1299
|
}
|
|
1290
1300
|
const visit = (i: number, posed: Posed): void => {
|
|
1291
1301
|
frames.push(frameOf(i, i * step, posed, draw(), opts));
|
|
1292
1302
|
};
|
|
1293
1303
|
// Without an environment the call is the one it always was, so a poser that takes none is asked nothing new.
|
|
1294
|
-
if (opts?.
|
|
1304
|
+
if (opts?.physicsMode === 'off') poser.animation(name, opts.skin, fps, count, visit, opts.environment === undefined ? undefined : environmentAt(opts.environment, fps), opts.channels, undefined, undefined, undefined, 'off');
|
|
1305
|
+
else if (opts?.channels) poser.animation(name, opts.skin, fps, count, visit, opts.environment === undefined ? undefined : environmentAt(opts.environment, fps), true);
|
|
1295
1306
|
else if (opts?.environment === undefined) poser.animation(name, opts?.skin, fps, count, visit);
|
|
1296
1307
|
else poser.animation(name, opts.skin, fps, count, visit, environmentAt(opts.environment, fps));
|
|
1297
1308
|
return frames;
|
|
@@ -1338,13 +1349,14 @@ export function prerollWarmPoints(start: number, fps: number): number {
|
|
|
1338
1349
|
}
|
|
1339
1350
|
|
|
1340
1351
|
/**
|
|
1341
|
-
* The
|
|
1342
|
-
* `
|
|
1343
|
-
* before `prerollStart`, one
|
|
1344
|
-
* `start + i / fps` for `i = 1..count
|
|
1345
|
-
* pose frame 0
|
|
1352
|
+
* The walk a pre-roll of `preroll` loops takes, for `Poser.animation`'s `times`
|
|
1353
|
+
* and `clockSteps` (issues #1684, #1687): the track times — the render's grid
|
|
1354
|
+
* `j / fps` up to the last point before `prerollStart`, one step onto it, then
|
|
1355
|
+
* the loop recorded, `start + i / fps` for `i = 1..count` — and what each step
|
|
1356
|
+
* moves the physics clock by. `first` is the index of `start`, the pose frame 0
|
|
1357
|
+
* is.
|
|
1346
1358
|
*
|
|
1347
|
-
* ⭐ **The
|
|
1359
|
+
* ⭐ **The track lands on `start` exactly.** Both posers step a track by the
|
|
1348
1360
|
* differences of `times` and add them up (`trackTime += delta`), and every
|
|
1349
1361
|
* difference before `start` is exact — two floats within a factor of two of
|
|
1350
1362
|
* each other subtract without rounding — so each running sum is the time it
|
|
@@ -1352,15 +1364,48 @@ export function prerollWarmPoints(start: number, fps: number): number {
|
|
|
1352
1364
|
* `preroll · count` steps instead lands on `preroll · count / fps`, which is
|
|
1353
1365
|
* the wrap only where `duration · fps` is whole: elsewhere the loop recorded
|
|
1354
1366
|
* started at the wrong phase while frame 0 was filed at time 0.
|
|
1355
|
-
|
|
1356
|
-
|
|
1367
|
+
*
|
|
1368
|
+
* ⭐ **The physics clock lands on the grid, not on `start`** (issue #1687). The
|
|
1369
|
+
* step onto `start` is a frame only where the duration is exactly `count /
|
|
1370
|
+
* fps`. Where it is not — a duration read as a float32, `count / fps` less
|
|
1371
|
+
* `δ` — it is a frame short by `preroll · δ`: 3.2e-7 s at 2 loops of a
|
|
1372
|
+
* 2.6666665 s idle at 12 fps, against the 5.0e-9 s of `PHYSICS_CLOCK_LEAD`
|
|
1373
|
+
* the clock has gathered there. A physics constraint takes the whole steps
|
|
1374
|
+
* that fit, so a clock moved by that short step takes one fewer (4 in place
|
|
1375
|
+
* of 5 at 12 fps against 60 Hz) and leaves 0.99998 of a step remaining where
|
|
1376
|
+
* the plain walk leaves 3e-7 of one; every frame after keeps that phase, and
|
|
1377
|
+
* the loop recorded is a fresh transient — measured on the selftest's
|
|
1378
|
+
* four-link `preroll_probe`, its springs 26.4 units off the plain walk's loop
|
|
1379
|
+
* on a stepped idle and 37.6 on an eased one, and its seam 1.38× its largest
|
|
1380
|
+
* step after 10 loops (`PRE08`). ⚠️ The ⭐ above once stood alone as the
|
|
1381
|
+
* walk's whole claim: it is true of the track and said nothing of the
|
|
1382
|
+
* physics, which took the same step.
|
|
1383
|
+
*
|
|
1384
|
+
* So the physics clock is stepped by the same differences except the step
|
|
1385
|
+
* onto `start`, which moves it to the grid point nearest the wrap,
|
|
1386
|
+
* `round(start · fps) / fps`, and not to `start`: a whole frame where the
|
|
1387
|
+
* wrap sits just short of a grid point, and nothing where it sits just past
|
|
1388
|
+
* one — `δ` below 0, where that grid point is the last before `start` and the
|
|
1389
|
+
* step onto `start` is the sliver past it. The loop recorded is then loop
|
|
1390
|
+
* `preroll + 1` of a plain looping walk on the uniform grid, its physics clock
|
|
1391
|
+
* off that walk's by rounding alone (`PRE07`), and frame 0 is still the wrap:
|
|
1392
|
+
* the animation is re-phased onto it by `preroll · δ`, the one difference
|
|
1393
|
+
* between the two that the physics answers. Where the duration is exactly
|
|
1394
|
+
* `count / fps`, `start` is that grid point and the clock steps are the
|
|
1395
|
+
* track's, so nothing moves; on a loop that is no whole number of frames at
|
|
1396
|
+
* all (`PRE06`'s 1.03 s at 12 fps) the physics stays on the frame grid the
|
|
1397
|
+
* same way and the animation is re-phased by under half a frame.
|
|
1398
|
+
*/
|
|
1399
|
+
export function prerollTimes(duration: number, fps: number, preroll: number): { times: number[]; first: number; clock: number[] } {
|
|
1357
1400
|
const start = prerollStart(duration, preroll);
|
|
1358
1401
|
const count = Math.round(duration * fps);
|
|
1359
1402
|
const first = prerollWarmPoints(start, fps);
|
|
1360
1403
|
const times = Array.from({ length: first }, (_, j) => j / fps);
|
|
1361
1404
|
times.push(start);
|
|
1362
1405
|
for (let i = 1; i <= count; i++) times.push(start + i / fps);
|
|
1363
|
-
|
|
1406
|
+
const clock = times.slice(1).map((t, i) => t - times[i]);
|
|
1407
|
+
if (first > 0) clock[first - 1] = Math.round(start * fps) / fps - times[first - 1];
|
|
1408
|
+
return { times, first, clock };
|
|
1364
1409
|
}
|
|
1365
1410
|
|
|
1366
1411
|
/**
|
|
@@ -1373,7 +1418,7 @@ export function prerollTimes(duration: number, fps: number, preroll: number): {
|
|
|
1373
1418
|
*/
|
|
1374
1419
|
export function sampleSetupPose(source: PoseSource, opts?: PoseOptions): Frame[] {
|
|
1375
1420
|
const poser = poserOf(source);
|
|
1376
|
-
const posed = opts?.channels ? poser.setup(opts.skin, true) : poser.setup(opts?.skin);
|
|
1421
|
+
const posed = opts?.physicsMode === 'off' ? poser.setup(opts.skin, opts.channels, 'off') : opts?.channels ? poser.setup(opts.skin, true) : poser.setup(opts?.skin);
|
|
1377
1422
|
return [frameOf(0, 0, posed, drawResolver(poser, opts)(), opts)];
|
|
1378
1423
|
}
|
|
1379
1424
|
|
|
@@ -1697,6 +1742,7 @@ export interface GeometryFile {
|
|
|
1697
1742
|
/** The setup pose, sampled the way `sampleSetupPose` samples it. */
|
|
1698
1743
|
setup: Omit<GeometryFrame, 'index' | 'time'>;
|
|
1699
1744
|
frames: GeometryFrame[];
|
|
1745
|
+
physicsMode?: 'off';
|
|
1700
1746
|
}
|
|
1701
1747
|
|
|
1702
1748
|
/**
|
|
@@ -1744,6 +1790,7 @@ export function geometryFileOf(
|
|
|
1744
1790
|
skin: string | undefined,
|
|
1745
1791
|
environment?: EnvironmentStep[],
|
|
1746
1792
|
preroll?: number,
|
|
1793
|
+
physicsMode?: 'off',
|
|
1747
1794
|
): GeometryFile {
|
|
1748
1795
|
const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
|
|
1749
1796
|
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
@@ -1760,7 +1807,7 @@ export function geometryFileOf(
|
|
|
1760
1807
|
...geometryFrame(frame, `frame ${frame.index}`),
|
|
1761
1808
|
}));
|
|
1762
1809
|
const poser = poserOf(source);
|
|
1763
|
-
const setupFrame = sampleSetupPose(poser, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
|
|
1810
|
+
const setupFrame = sampleSetupPose(poser, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true, ...(physicsMode === undefined ? {} : { physicsMode }) })[0];
|
|
1764
1811
|
const setup = geometryFrame(setupFrame, 'the setup pose');
|
|
1765
1812
|
const file: GeometryFile = {
|
|
1766
1813
|
spec: GEOMETRY_SPEC,
|
|
@@ -1769,6 +1816,7 @@ export function geometryFileOf(
|
|
|
1769
1816
|
...(skin === undefined ? {} : { skin }),
|
|
1770
1817
|
...(environment === undefined ? {} : { environment }),
|
|
1771
1818
|
...(preroll === undefined ? {} : { preroll }),
|
|
1819
|
+
...(physicsMode === undefined ? {} : { physicsMode }),
|
|
1772
1820
|
fps,
|
|
1773
1821
|
viewport: sidecarViewport(viewport),
|
|
1774
1822
|
bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
|
package/src/sheet.ts
CHANGED
|
@@ -120,6 +120,7 @@ export interface SheetManifest {
|
|
|
120
120
|
environment?: EnvironmentStep[];
|
|
121
121
|
/** The whole loops walked before the loop these frames record (`render --preroll`, issue #1684); absent without it. */
|
|
122
122
|
preroll?: number;
|
|
123
|
+
physicsMode?: 'off';
|
|
123
124
|
pinned?: PinnedViewport;
|
|
124
125
|
fps: number;
|
|
125
126
|
duration: number;
|
|
@@ -185,6 +186,7 @@ export interface BuildSheetSetOptions {
|
|
|
185
186
|
subset?: SlotSubset;
|
|
186
187
|
environment?: EnvironmentStep[];
|
|
187
188
|
preroll?: number;
|
|
189
|
+
physicsMode?: 'off';
|
|
188
190
|
pinned?: PinnedViewport;
|
|
189
191
|
/** For negative-control testing in `selftest.ts` only: reverse the input order handed to `packAtlas`. */
|
|
190
192
|
reversePackInputs?: boolean;
|
|
@@ -276,6 +278,7 @@ export function sheetNamespaceOf(opts: {
|
|
|
276
278
|
subset?: SlotSubset;
|
|
277
279
|
environment?: EnvironmentStep[];
|
|
278
280
|
preroll?: number;
|
|
281
|
+
physicsMode?: 'off';
|
|
279
282
|
}): string {
|
|
280
283
|
const rig = sanitizeNamespaceSegment(opts.rigName);
|
|
281
284
|
const anim = sanitizeNamespaceSegment(opts.setName);
|
|
@@ -287,6 +290,7 @@ export function sheetNamespaceOf(opts: {
|
|
|
287
290
|
variants.push(`env=${digest}`);
|
|
288
291
|
}
|
|
289
292
|
if (opts.preroll !== undefined) variants.push(`preroll=${opts.preroll}`);
|
|
293
|
+
if (opts.physicsMode !== undefined) variants.push(`physics=${opts.physicsMode}`);
|
|
290
294
|
return variants.length === 0 ? `${rig}/${anim}` : `${rig}/${anim}/${variants.join('+')}`;
|
|
291
295
|
}
|
|
292
296
|
|
|
@@ -721,6 +725,7 @@ export function buildSheetSet(opts: BuildSheetSetOptions): SheetSetPack {
|
|
|
721
725
|
subset: opts.subset,
|
|
722
726
|
environment: opts.environment,
|
|
723
727
|
preroll: opts.preroll,
|
|
728
|
+
physicsMode: opts.physicsMode,
|
|
724
729
|
});
|
|
725
730
|
|
|
726
731
|
const padding = SHEET_PADDING;
|
|
@@ -875,6 +880,7 @@ export function buildSheetSet(opts: BuildSheetSetOptions): SheetSetPack {
|
|
|
875
880
|
: { hidden: [...opts.subset.names] }),
|
|
876
881
|
...(opts.environment === undefined ? {} : { environment: opts.environment }),
|
|
877
882
|
...(opts.preroll === undefined ? {} : { preroll: opts.preroll }),
|
|
883
|
+
...(opts.physicsMode === undefined ? {} : { physicsMode: opts.physicsMode }),
|
|
878
884
|
...(opts.pinned === undefined ? {} : { pinned: opts.pinned }),
|
|
879
885
|
fps: opts.fps,
|
|
880
886
|
duration,
|