spine-rigc 3.1.0 → 3.2.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 CHANGED
@@ -129,7 +129,7 @@ commands take it and what its default is.
129
129
  | `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 <a id="cmd-ingest"></a> |
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
- | `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. `--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> |
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
133
  | `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
134
  | `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
135
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture <a id="cmd-pose"></a> |
@@ -389,6 +389,7 @@ What the flags mean:
389
389
  | `--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
390
  | `--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
391
  | `--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** |
392
393
  | `--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 |
393
394
  | `--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** |
394
395
  | `--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` |
@@ -5615,7 +5616,8 @@ that poses through its posers (`check`, `bonedist`, `inspect`, `--geometry`), ru
5615
5616
  the physics clock `2⁻³⁰` faster than the frames, so every such frame takes the steps
5616
5617
  exact arithmetic takes; the animation time is unchanged. A spring that has converged
5617
5618
  by the loop point therefore closes, which the loop-seam suite's `LS06` holds at all
5618
- five rates. A player that steps the core entry's live tracks at a fixed rate — a
5619
+ five rates. A render from rest releases the spring at frame 0, so on a looping idle
5620
+ it has not converged by then; `render --preroll` (§8.13) walks whole loops first. A player that steps the core entry's live tracks at a fixed rate — a
5619
5621
  capture, an export, a test harness — is the render's case and takes the render's
5620
5622
  clock: `openTrack` and `openTracks` accept `clock`, what each step moves the physics
5621
5623
  clock by, and `physicsClockStep` (with `PHYSICS_CLOCK_LEAD`) is exported from
@@ -9825,6 +9827,100 @@ not draw order, colour, occlusion, tears, ghost lines or rendered optical flow.
9825
9827
  Compare the two fits, the per-sample curves and an explicit control before
9826
9828
  attributing residual to relief. No assertion, pass mark or build gate is added.
9827
9829
 
9830
+ ### 8.13 A loop with its physics warm — `render --preroll`
9831
+
9832
+ ```bash
9833
+ bun cli.ts render --candidate path/to/spine --preroll 2 [--animation idle] [--geometry] [--sheet] [--poser core|spine]
9834
+ ```
9835
+
9836
+ A render walks one pass of each animation from rest: frame 0 resets the physics,
9837
+ and every later frame steps it. On a looping idle that is the one frame a player
9838
+ never shows — a player's looping track carries the physics across every wrap — so
9839
+ a video or a sprite sheet cut from one pass shows every spring released from rest
9840
+ at every wrap, and trimming or cross-fading frames hides it without making the
9841
+ loop honest. `--preroll <n>` sets the track **looping** and walks `n` whole loops
9842
+ first, drawn by no frame, then records the next loop:
9843
+
9844
+ - **The walk.** `count` is the set's frame count at the rate,
9845
+ `round(duration · fps)`. From the same reset at frame 0 a render from rest
9846
+ takes, the track steps the render's grid `j / fps` up to the last point before
9847
+ track time `n · duration`, takes one shorter step onto it, then the `count`
9848
+ 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
+ duration, what spine-core's `setAnimation(0, name, true)` does and what the
9851
+ core's live track does looping (`openTrack({ loop: true })`). Both posers add
9852
+ the steps up the same way, and every one before the wrap subtracts without
9853
+ rounding, so the walk lands on `n · duration` exactly: recorded frame `i` is
9854
+ the pose at animation time `i / fps`, filed under index `i` and time `i / fps`,
9855
+ and the frame set has the shape a render from rest has.
9856
+ - **Why the shorter step.** A loop that is not a whole number of frames — 1.03 s
9857
+ at 12 fps is 12.36 — is not `count` frames long, so `n · count` steps of
9858
+ `1 / fps` land `n · (count / fps − duration)` seconds off the wrap: the loop
9859
+ recorded would start mid-loop while frame 0 is filed at time 0, and
9860
+ `frames.json` and `geometry.json` would misstate every frame's time. `PRE06`
9861
+ holds a stepped key on such a loop — 0 on its first half, 12 on its second —
9862
+ to the render from rest's at every frame of pre-rolls of 1 and 3, on both
9863
+ posers, and the uniform grid planted on the core is red at frame 0 on both.
9864
+ Both posers read a duration off float32 key times, so `n · duration` is a
9865
+ float and the wrap is hit exactly; for a duration that is not, where the
9866
+ product rounds below the wrap — the loop's end rather than its start — the
9867
+ walk lands one float past it (`prerollStart`).
9868
+ - **What moves.** Only what carries from one step to the next: the physics. A
9869
+ keyed bone is posed at the loop time the render from rest draws it at, to the
9870
+ rounding of the steps' sums — on the skirt fixture's hip, measured on this walk
9871
+ at 7, 13, 24 and 30 fps after 1 and 3 loops, no position or matrix entry moved
9872
+ by more than 1.7e-13 — and on `PRE06`'s stepped key to the bit.
9873
+ - **Both posers, one walk.** The selftest's `render-preroll` suite renders three
9874
+ rigs through the core and through `--poser spine` and holds every PNG,
9875
+ `geometry.json`, sheet and `frames.json` equal (`PRE01`), and plants on the core
9876
+ the two walks rejected — the loop flag ignored, so the track holds at its
9877
+ duration through the pre-roll, and the pre-roll dropped, so the loop recorded is
9878
+ walked from rest — each red against spine-core (`PRE03`).
9879
+ - **What it buys, measured.** On the skirt fixture (six chains of five links on
9880
+ physics, swayed from the hip on a 2 s loop) at 12 fps, frame 0 against the last
9881
+ frame of each physics bone — its tip and its rotation — ends past that bone's own
9882
+ largest step between two adjacent frames on 29 of 30 bones from rest, the worst
9883
+ at 4.5 times it; after one loop of pre-roll every bone ends within 0.015 of it
9884
+ (`PRE02`, which reads it off the `geometry.json` the CLI wrote, on both posers).
9885
+
9886
+ **The files.** `frames.json`, each `geometry.json` and each `sheet-manifest.json`
9887
+ record `preroll: <n>`, and a sheet's frame-key namespace ends `/preroll=<n>`, so a
9888
+ warm sheet and one from rest loaded into one texture cache do not share keys. The
9889
+ framing reads it, for `--environment`'s reason: a spring warmed swings otherwise
9890
+ than one released, and the box holds what the frames draw — so the grid can differ
9891
+ from the render from rest; compare the two in world units, in `geometry.json`.
9892
+ Without the flag every walk holds at its duration as it always did, spine-core's
9893
+ track is set not looping, and every file is the bytes it always was (`PRE04`, and
9894
+ the gallery's render and emit bases, `RH05` and `EH06`). `check` poses its
9895
+ candidate from rest, so a frame set drawn after a pre-roll is refused as a
9896
+ reference by name rather than scored.
9897
+
9898
+ **Refused by name**, exit 2, nothing written (`PRE05`):
9899
+
9900
+ - a value that is not a whole number of 1 or more — `--preroll <v>: negative`,
9901
+ `not a whole number of loops`, `not a number`, and `--preroll 0 walks no loop
9902
+ before the one recorded, which is the render from rest — leave --preroll out
9903
+ for it`;
9904
+ - a walk past what a step list holds — `"<set>" would walk <N> steps at <fps> fps,
9905
+ more than a walk's step list holds (4294967295)`;
9906
+ - a pre-roll this rig would not walk: a skeleton with no animation; one that
9907
+ declares no physics constraint (`nothing carries from one loop into the next`);
9908
+ a set of 0 frames at the rate (`"<set>" lasts <d>s, which is 0 frames at <fps>
9909
+ fps`);
9910
+ - `--environment` beside it: a schedule's `t` could be read from the first frame
9911
+ recorded or from the first loop walked, the two put the physics in different
9912
+ worlds while it warms, and nothing settles which; and `--parallax`, whose dial
9913
+ samples are independent reset poses.
9914
+
9915
+ **Why loops and not a start in seconds.** A whole number of loops lands recorded
9916
+ frame `i` on the animation time the render from rest draws it at, so the two renders
9917
+ compare frame for frame and only the physics differs. A start in seconds would
9918
+ need a second rule for where the recorded window falls on the frame grid, and
9919
+ could put it mid-loop. The mesh comparison's `warmupSteps` (MESH_REDUCTION, P10),
9920
+ still refused above 0, would reach the same looping walk; counted in steps rather
9921
+ than loops, it would move every compared frame off its `FrameRef` time unless the
9922
+ steps were whole loops.
9923
+
9828
9924
  ## 9. Checking against the frames — `rigc check`
9829
9925
 
9830
9926
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "3.1.0",
3
+ "version": "3.2.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/check.ts CHANGED
@@ -1298,6 +1298,17 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
1298
1298
  'difference between two worlds. Render the reference frames without --environment.',
1299
1299
  );
1300
1300
  }
1301
+ // A frame set drawn after a pre-roll (`render --preroll`, issue #1684) shows the physics warm; check poses the
1302
+ // candidate from rest, so the score would be a number about the difference between a warm loop and a cold one.
1303
+ const referencePreroll = located.sidecar?.preroll;
1304
+ if (referencePreroll !== undefined) {
1305
+ throw new CheckError(
1306
+ `${FRAMES_SIDECAR} records that these frames were rendered after a pre-roll (\`rigc render --preroll ${String(referencePreroll)}\`: ` +
1307
+ 'that many loops walked on a looping track before the loop recorded, the physics carried), and check poses the candidate ' +
1308
+ 'from rest — so the comparison would be a number about the difference between a warm loop and a cold one. ' +
1309
+ 'Render the reference frames without --preroll.',
1310
+ );
1311
+ }
1301
1312
  if (referenceSkin === null && options.skin !== undefined) {
1302
1313
  notes.push(
1303
1314
  `the candidate is posed under skin ${JSON.stringify(options.skin)} and the reference frames record no skin ` +
@@ -47,7 +47,7 @@ import { buildSheetSet, SHEET_MANIFEST_FILE, type SheetSetPack, SheetUsageError,
47
47
  import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText } from '../seam.ts';
48
48
  import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
49
49
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
50
- import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, frameSetDir, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, sampleSetupPose, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser, type Viewport, viewportFor } from '../render_shared.ts';
50
+ import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, frameSetDir, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, prerollStart, prerollWarmPoints, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, sampleSetupPose, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser, type Viewport, viewportFor } from '../render_shared.ts';
51
51
  import { type CompileResult } from '../types.ts';
52
52
  import { attachmentRegionJoins } from '../region_joins.ts';
53
53
  import { cmdRepack } from './repack.ts';
@@ -507,6 +507,69 @@ function readEnvironmentFlag(flags: Record<string, string>): { path: string; ste
507
507
  }
508
508
  }
509
509
 
510
+ /**
511
+ * `--preroll <n>` read (issue #1684): how many whole loops of each animation the
512
+ * render walks on a looping track before the loop it records — or refused as a
513
+ * usage error, naming the value found and what is required, before anything is
514
+ * read off the candidate. `undefined` without the flag, which is the render from
515
+ * rest it always was.
516
+ *
517
+ * ⭐ A count of loops rather than a start in seconds: a whole number of loops
518
+ * puts the recorded frame `i` at the loop time the render without the flag draws
519
+ * it at (to the running sum's rounding), so the two renders compare frame for
520
+ * frame and only the physics differs — a start in seconds would need a second
521
+ * rule for where the window falls on the frame grid, and could land mid-loop.
522
+ */
523
+ function readPrerollFlag(flags: Record<string, string>): number | undefined {
524
+ const given = flags.preroll;
525
+ if (given === undefined) return undefined;
526
+ const wants = 'it takes how many whole loops of each animation to walk, on a looping track, before the loop it records — 1 or more';
527
+ const value = given.trim() === '' ? Number.NaN : Number(given);
528
+ if (!Number.isFinite(value)) throw new UsageError(`--preroll ${JSON.stringify(given)}: not a number — ${wants}`);
529
+ if (value < 0) throw new UsageError(`--preroll ${given}: negative — ${wants}`);
530
+ if (!Number.isInteger(value)) throw new UsageError(`--preroll ${given}: not a whole number of loops — ${wants}`);
531
+ if (value === 0) throw new UsageError(`--preroll 0 walks no loop before the one recorded, which is the render from rest — leave --preroll out for it; ${wants}`);
532
+ return value;
533
+ }
534
+
535
+ /** The most steps a walk's step list holds: the longest array the language makes (`2^32 − 1`). */
536
+ const WALK_STEPS_HELD = 2 ** 32 - 1;
537
+
538
+ /**
539
+ * The refusal of a pre-roll this candidate, at this rate, would not walk (issue
540
+ * #1684), each a frame set from rest printed under a flag that says it is warm,
541
+ * or a walk that could not be taken:
542
+ *
543
+ * - a skeleton with no animation, whose one frame is the setup pose;
544
+ * - a skeleton that declares no physics constraint, where nothing carries from
545
+ * one loop into the next;
546
+ * - a set drawn that spans no frame at `fps`, whose loop takes no step;
547
+ * - a walk longer than a step list holds (`WALK_STEPS_HELD`), which would
548
+ * otherwise die on the array's own error rather than on a name.
549
+ *
550
+ * `durations` are the poser's, of the sets this run draws; `physics` the
551
+ * physics constraints the skeleton declares.
552
+ */
553
+ function prerollRefusal(preroll: number, fps: number, durations: ReadonlyArray<{ name: string; duration: number }>, physics: readonly string[]): string | null {
554
+ if (durations.length === 0) return `--preroll ${preroll}: the skeleton has no animation — its one frame is the setup pose, where nothing loops and no physics step runs`;
555
+ if (physics.length === 0) {
556
+ return `--preroll ${preroll}: the skeleton declares no physics constraint, so nothing carries from one loop into the next and the loop recorded would be the render from rest`;
557
+ }
558
+ const still = durations.find(({ duration }) => Math.round(duration * fps) === 0);
559
+ if (still !== undefined) {
560
+ return (
561
+ `--preroll ${preroll}: "${still.name}" lasts ${still.duration}s, which is 0 frames at ${fps} fps — a loop of it walks no step, so no pre-roll ` +
562
+ 'moves its physics. Name another set with --animation, or raise --fps'
563
+ );
564
+ }
565
+ for (const { name, duration } of durations) {
566
+ // `prerollTimes`' own length, counted rather than built: the grid before the wrap, the step onto it, and the loop recorded.
567
+ const steps = prerollWarmPoints(prerollStart(duration, preroll), fps) + Math.round(duration * fps);
568
+ if (steps > WALK_STEPS_HELD) return `--preroll ${preroll}: "${name}" would walk ${steps} steps at ${fps} fps, more than a walk's step list holds (${WALK_STEPS_HELD})`;
569
+ }
570
+ return null;
571
+ }
572
+
510
573
  /**
511
574
  * The refusals of a well-formed schedule that this candidate, at this rate,
512
575
  * would quietly not draw (issue #1416) — each one a render that would print a
@@ -657,6 +720,15 @@ export function cmdRender(flags: Record<string, string>): void {
657
720
  const fps = readPositiveNumber(flags, 'fps', PROTOCOL_FPS, 1);
658
721
  // Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
659
722
  const environment = readEnvironmentFlag(flags);
723
+ // The pre-roll (issue #1684), read and refused as early, and refused beside a schedule: under a pre-roll a schedule's
724
+ // `t` could be read on the recording's clock or on the walk's, the two give different physics, and nothing settles it.
725
+ const preroll = readPrerollFlag(flags);
726
+ if (preroll !== undefined && environment !== undefined) {
727
+ throw new UsageError(
728
+ `--preroll takes no --environment: a schedule's t could be read from the first frame recorded or from the first loop walked, ` +
729
+ 'and the two put the physics in different worlds while it warms. Render with one of them',
730
+ );
731
+ }
660
732
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
661
733
  // A box given rather than fitted (issue #1414), read before a line is printed, so a malformed one is refused first.
662
734
  const given = readGivenViewport(flags);
@@ -778,7 +850,7 @@ export function cmdRender(flags: Record<string, string>): void {
778
850
  posersAsked(flags),
779
851
  );
780
852
  if (flags.parallax !== undefined) {
781
- 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', 'viewport', 'viewport-from']) {
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']) {
782
854
  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`);
783
855
  }
784
856
  if (flags.poser !== undefined && flags.poser !== 'core') throw new UsageError('--parallax requires --poser core: it reads only a rigc compiled model');
@@ -824,15 +896,22 @@ export function cmdRender(flags: Record<string, string>): void {
824
896
  // the frames draw the subset and the box is still the whole rig's.
825
897
  // The environment rides on the same object for the same reason (issue #1416): the framing reads it, so the box holds
826
898
  // what the frames under it draw.
899
+ // The pre-roll rides on it for the environment's reason (issue #1684): it changes the pose, so the framing reads it.
827
900
  const pose =
828
- skin === undefined && subset === undefined && environment === undefined
901
+ skin === undefined && subset === undefined && environment === undefined && preroll === undefined
829
902
  ? undefined
830
- : { ...(skin === undefined ? {} : { skin }), ...subsetFields(subset), ...(environment === undefined ? {} : { environment: environment.steps }) };
903
+ : {
904
+ ...(skin === undefined ? {} : { skin }),
905
+ ...subsetFields(subset),
906
+ ...(environment === undefined ? {} : { environment: environment.steps }),
907
+ ...(preroll === undefined ? {} : { preroll }),
908
+ };
831
909
  if (skin !== undefined) console.log(` .. skin ${skin}`);
832
910
  if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
833
911
  if (environment !== undefined && facts.animations.length === 0) {
834
912
  throw new UsageError(environmentRefusal(flags.environment, environment.steps, skeletonText, fps, []) ?? '');
835
913
  }
914
+ if (preroll !== undefined && facts.animations.length === 0) throw new UsageError(prerollRefusal(preroll, fps, [], physicsDeclared) ?? '');
836
915
 
837
916
  // Rigc's own core when the candidate is a rigc build, and spine-core
838
917
  // otherwise, or where the core refuses the input by name. Never silently:
@@ -853,6 +932,11 @@ export function cmdRender(flags: Record<string, string>): void {
853
932
  const refusal = environmentRefusal(flags.environment, environment.steps, skeletonText, fps, drawn);
854
933
  if (refusal !== null) throw new UsageError(refusal);
855
934
  }
935
+ // A pre-roll this candidate would not walk is refused off the same durations, for the same reason (issue #1684).
936
+ if (preroll !== undefined) {
937
+ const refusal = prerollRefusal(preroll, fps, poser.animations.filter((a) => only === undefined || a.name === only), physicsDeclared);
938
+ if (refusal !== null) throw new UsageError(refusal);
939
+ }
856
940
  // `null` is a skeleton that posed no vertex at all. One that posed a vertex
857
941
  // it cannot frame — Infinity or NaN — is thrown from the framing as a
858
942
  // `GeometryError` naming the number (issue #873), and one whose every vertex
@@ -935,7 +1019,7 @@ export function cmdRender(flags: Record<string, string>): void {
935
1019
  if (geometry) {
936
1020
  for (const [name, frames] of sampled) {
937
1021
  const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
938
- geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps)));
1022
+ geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps, preroll)));
939
1023
  }
940
1024
  }
941
1025
  // The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
@@ -1066,6 +1150,7 @@ export function cmdRender(flags: Record<string, string>): void {
1066
1150
  skin,
1067
1151
  subset,
1068
1152
  environment: environment?.steps,
1153
+ preroll,
1069
1154
  pinned,
1070
1155
  }),
1071
1156
  );
@@ -1086,6 +1171,12 @@ export function cmdRender(flags: Record<string, string>): void {
1086
1171
  `wind answered by ${response.wind.join(', ') || 'none'}; gravity by ${response.gravity.join(', ') || 'none'}`,
1087
1172
  );
1088
1173
  }
1174
+ if (preroll !== undefined) {
1175
+ console.log(
1176
+ ` .. preroll ${preroll} loop(s) of each set walked on a looping track before the loop recorded, drawn by no frame — ` +
1177
+ 'the physics carried across every wrap, as a player\'s looping track carries it',
1178
+ );
1179
+ }
1089
1180
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
1090
1181
  // Whether physics ran (issue #1467): said once for a rig with nothing to step, and per set below for one with.
1091
1182
  if (physicsDeclared.length === 0) console.log(` .. ${PHYSICS_NONE_LINE}`);
@@ -1237,6 +1328,8 @@ export function cmdRender(flags: Record<string, string>): void {
1237
1328
  ...subsetFields(subset),
1238
1329
  // Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
1239
1330
  ...(environment === undefined ? {} : { environment: environment.steps }),
1331
+ // Written only when a pre-roll was asked for (issue #1684), for the same reason.
1332
+ ...(preroll === undefined ? {} : { preroll }),
1240
1333
  // Written only when the box was given (issue #1414): a fitted render says nothing and stays the bytes it was.
1241
1334
  ...(given === undefined ? {} : { pinned: pinnedRecord(given.by, outside) }),
1242
1335
  background: BACKGROUND,
package/src/cli/shared.ts CHANGED
@@ -1071,7 +1071,7 @@ const FLAG_MEANINGS: Record<string, string> = {
1071
1071
  'Each frame with a moving pair also gets an outline plate, overlaps-f<NNNN>.png: every slot in a moving pair as its contour in its own ' +
1072
1072
  'colour, dashed where a slot drawn later covers it, named at its top. The render reports what was drawn; who should be in front is the ' +
1073
1073
  "author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Not with --slot/--hide",
1074
- parallax: 'read one named slider dial through the core poser on 9 evenly spaced values: parallax.json plus setup/extreme/diff/affine-residual heat-map parallax.png; geometry only, no assertion or pass threshold. A separate dial render mode; takes no animation, fps, other diagnostics, sheet, subset, environment or viewport flags',
1074
+ parallax: 'read one named slider dial through the core poser on 9 evenly spaced values: parallax.json plus setup/extreme/diff/affine-residual heat-map parallax.png; geometry only, no assertion or pass threshold. A separate dial render mode; takes no animation, fps, other diagnostics, sheet, subset, environment, preroll or viewport flags',
1075
1075
  'extremes-only': 'write only extremes.json and readable extremes.png per set, with the same posed grid and bytes as --extremes; no ordinary frame PNGs, contact.png or frames.json. Implies --extremes; takes no --sheet, --sheet-format, --page-edges, --geometry, --folds, --pass-through, --seam, --overlaps or --angle-range',
1076
1076
  extremes: 'also write extremes.json and a native six-panel extremes.png per set: setup, geometry-pair min/max and premultiplied RGBA diffs, with a legend. Per-channel extrema are recorded separately and valid channels survive geometry/image SKIPs; channel refusals are named separately. Still geometry reports SKIP; a reading, not a gate. Attachment identity/count changes record a named SKIP without changing render exit; supports --slot/--hide on both posers',
1077
1077
  seam:
@@ -1108,6 +1108,13 @@ 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
+ preroll:
1112
+ 'walk each set this many whole loops on a looping track before the loop recorded, drawn by no frame, the physics carried ' +
1113
+ '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 ' +
1114
+ 'pose at track time (n·count + i)/fps, filed under index i and time i/fps; on either poser. frames.json, geometry.json and ' +
1115
+ 'sheet-manifest.json record it, and the framing reads it. A value that is not a whole number of 1 or more, a walk past what a step ' +
1116
+ 'list holds, a skeleton with no animation or no physics constraint, a set of 0 frames, and --environment or --parallax beside it ' +
1117
+ 'are refused by name; check refuses such a frame set as a reference',
1111
1118
  poser:
1112
1119
  "`render`, `inspect` and `check`: which implementation poses the frames (on `check`, the candidate's) — `core` (rigc's own, reading the skeleton.model.json a " +
1113
1120
  'build writes beside the pair) or `spine` (spine-core). Default: `core` when that document and the atlas sit ' +
@@ -1300,6 +1307,7 @@ const FLAG_VALUES: Record<string, string> = {
1300
1307
  'texture-from': '<path>',
1301
1308
  poser: 'core|spine',
1302
1309
  environment: '<schedule.json>',
1310
+ preroll: '<n>',
1303
1311
  'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
1304
1312
  seam: '<slot>:<slot>[,<slot>:<slot>…]',
1305
1313
  'angle-range': '<bone>:<relative-to>[,…]',
@@ -1768,6 +1776,7 @@ export const COMMANDS: CommandDoc[] = [
1768
1776
  'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--extremes | --extremes-only] [--poser core|spine] [--out render/]',
1769
1777
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1770
1778
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1779
+ 'rigc render … --preroll <n> (n whole loops walked on a looping track first, so the loop recorded has its physics warm)',
1771
1780
  'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
1772
1781
  'rigc render … --seam <slot>:<slot>[,…] (joint gaps, visible ghosts and each named slot’s background exposure)',
1773
1782
  'rigc render … --parallax <dial> (geometry reading on the named dial, model/core only)',
@@ -1776,7 +1785,7 @@ export const COMMANDS: CommandDoc[] = [
1776
1785
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1777
1786
  'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
1778
1787
  ],
1779
- 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', 'poser', 'out'],
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'],
1780
1789
  overrides: {
1781
1790
  'page-edges': {
1782
1791
  meaning:
package/src/core/raw.ts CHANGED
@@ -620,6 +620,26 @@ export function poseRawAnimationEach(doc: CompiledDocument, animation: string, s
620
620
  walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_MODE : { ...RAW_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit, environment);
621
621
  }
622
622
 
623
+ /**
624
+ * The live track's looping mode (`./track.ts`, issue #1276) as a batch walk's:
625
+ * the animation time the track time wrapped by the duration, `reset` keys
626
+ * fired across the wrap, the clipped rows cut as the raw entry cuts them, and
627
+ * no value that is not finite kept.
628
+ */
629
+ const RAW_LOOPED_MODE: WalkMode = { loop: true, keep: false, live: true };
630
+
631
+ /**
632
+ * `poseRawAnimationEach` on a looping track (issue #1684): the same walk in the
633
+ * live track's looping mode (`RAW_LOOPED_MODE`) — the walk `render --preroll`
634
+ * takes, which carries the physics across every wrap as a player's looping
635
+ * track does. Not part of the entry's promised surface: `src/render_core.ts`
636
+ * is its one caller, and spine-core's poser takes the same walk through
637
+ * `setAnimation(0, name, true)`.
638
+ */
639
+ export function poseRawLoopEach(doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, reset: RawReset, visit: (pose: RawPose, index: number) => void, clock?: (dt: number) => number, environment?: StepEnvironment): void {
640
+ walkEach(doc, animation, steps, plant, reset, clock === undefined ? RAW_LOOPED_MODE : { ...RAW_LOOPED_MODE, clock: (dt) => clock(dt) }, RAW_SHAPE, visit, environment);
641
+ }
642
+
623
643
  /**
624
644
  * What a batch walk sets on its physics before a pose (issue #1416, part C):
625
645
  * pose `index`'s change to the world every physics constraint answers — either
package/src/render.ts CHANGED
@@ -503,12 +503,14 @@ export function spinePoser(data: SkeletonData): Poser {
503
503
  slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
504
504
  subset: (opts, skin) => slotSubsetOf(data, opts, skin),
505
505
  setup: (skin) => spinePosed(setupPosed(data, skin)),
506
- animation: (name, skin, fps, count, visit, environment, _channels, times) => {
506
+ animation: (name, skin, fps, count, visit, environment, _channels, times, loop) => {
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
510
- // entry would wrap it back onto the first pose.
511
- state.setAnimation(0, name, false);
510
+ // entry would wrap it back onto the first pose. Looping only when asked (issue
511
+ // #1684, `render --preroll`): the walk a player's looping track takes, the
512
+ // physics carried across every wrap — the core walks it as `poseRawLoopEach`.
513
+ state.setAnimation(0, name, loop === true);
512
514
  skeleton.setupPose();
513
515
  const step = 1 / fps;
514
516
  // The physics clock runs `PHYSICS_CLOCK_LEAD` fast, so no frame ends on a physics step's tie (issue #1380).
@@ -18,7 +18,7 @@ import type { ChannelValue } from './render_shared.ts';
18
18
  * with the runtime it replaces — and "the core poser imports nothing from
19
19
  * spine-core" could then only be said, not read off the file. Here it can be:
20
20
  * the value imports below are the core, the atlas reader and nothing else, and
21
- * the one thing taken from `render.ts` is its types (erased at run time). The
21
+ * the renderer's types come from `render_shared.ts` (nothing from `render.ts`). The
22
22
  * modules that link spine-core stay three (`CUR07`).
23
23
  *
24
24
  * ## What each part of a pose is read from
@@ -98,12 +98,12 @@ import type {
98
98
  Poser,
99
99
  SkinRoster,
100
100
  SlotSubset,
101
- } from './render.ts';
101
+ } from './render_shared.ts';
102
102
  import { atlasRegionLookup, parseAtlasText } from './atlas.ts';
103
103
  import { pagesOfAtlas, spineFileSha256, type ModelPage } from './model.ts';
104
104
  import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.ts';
105
105
  import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
106
- import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawDial, poseRawSetup, type RawDrawn, type RawPose, type StepEnvironment } from './core/raw.ts';
106
+ import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawDial, poseRawLoopEach, poseRawSetup, type RawDrawn, type RawPose, type StepEnvironment } from './core/raw.ts';
107
107
  import type { TimelinePlant } from './core/animation.ts';
108
108
  import { stepPhysics, type PhysicsStepper } from './core/constraints_physics.ts';
109
109
  import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
@@ -367,14 +367,18 @@ 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) => void;
370
+ export type CoreRawWalk = (doc: CompiledDocument, animation: string, steps: readonly number[], plant: TimelinePlant, visit: (pose: RawPose, index: number) => void, environment?: StepEnvironment, loop?: boolean) => 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
- * is posed (issue #1416, `StepEnvironment`).
375
+ * is posed (issue #1416, `StepEnvironment`). `loop` (issue #1684, `render --preroll`) walks the track looping
376
+ * (`poseRawLoopEach`) — spine-core's poser sets the same track looping — and absent, the walk is the one it always was.
376
377
  */
377
- export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit, environment) => poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep, environment);
378
+ export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit, environment, loop) =>
379
+ loop === true
380
+ ? poseRawLoopEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep, environment)
381
+ : poseRawAnimationEach(doc, animation, steps, plant, 'animation', visit, physicsClockStep, environment);
378
382
 
379
383
  /**
380
384
  * The render's physics clock (issue #1380): defined beside the walk that takes it (`./core/raw.ts`), where the mesh
@@ -790,7 +794,7 @@ export function corePoser(
790
794
  const pose = poseRawSetup(input.doc, { through, ...observe });
791
795
  return corePosed(input, pose, through, [], values);
792
796
  },
793
- animation: (name, skin, fps, count, visit, environment, channels, times) => {
797
+ animation: (name, skin, fps, count, visit, environment, channels, times, loop) => {
794
798
  let values: ChannelValue[] | undefined;
795
799
  const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
796
800
  const input = inputOf(skin);
@@ -819,7 +823,7 @@ export function corePoser(
819
823
  } catch (thrown) {
820
824
  drawFailed.push(thrown);
821
825
  }
822
- }, environment);
826
+ }, environment, loop);
823
827
  if (drawFailed.length > 0) throw drawFailed[0];
824
828
  },
825
829
  rest: (skin, shown) => {
@@ -283,6 +283,16 @@ export interface FramesSidecar {
283
283
  * it as a reference by name.
284
284
  */
285
285
  environment?: EnvironmentStep[];
286
+ /**
287
+ * The whole loops each set walked on a looping track before the loop its
288
+ * frames record, when `render --preroll` gave them (issue #1684).
289
+ *
290
+ * ⭐ Absent on a render without it, for `skin`'s reason: every frame set
291
+ * written before the field existed is a render from rest, and says the same.
292
+ * A frame set carrying it shows the physics warm, which `check` does not
293
+ * pose, so `check` refuses it as a reference by name.
294
+ */
295
+ preroll?: number;
286
296
  /**
287
297
  * The box was given rather than fitted, when `render --viewport` or
288
298
  * `render --viewport-from` gave it (issue #1414): which flag, and every slot
@@ -501,6 +511,23 @@ export interface PoseOptions {
501
511
  environment?: readonly EnvironmentStep[];
502
512
  /** Also record the physics constraints the step that posed each frame stepped — see `Frame.physics` (issue #1467). */
503
513
  physics?: boolean;
514
+ /**
515
+ * Whole loops walked before the loop recorded — `render --preroll` (issue
516
+ * #1684): the track set looping, walked and drawn by no frame up to track
517
+ * time `preroll · duration` (`prerollTimes`), then the `count + 1` frames of
518
+ * the next loop, frame `i` the pose at track time `preroll · duration + i /
519
+ * fps` — animation time `i / fps` — filed under index `i` and time `i / fps`.
520
+ * The physics is carried across every wrap, as
521
+ * a player's looping track carries it, so a spring that converges is warm
522
+ * on the first frame recorded. Absent, the track holds at its duration and
523
+ * every pose is the one it always was. A whole number at or above 1; not
524
+ * with `environment`, whose clock under a pre-roll nothing settles.
525
+ *
526
+ * ⭐ **It is part of the shot, so the framing reads it**, for `environment`'s
527
+ * reason: a spring warmed from rest swings differently from one released
528
+ * from it, and the box holds what the frames draw.
529
+ */
530
+ preroll?: number;
504
531
  }
505
532
 
506
533
  /**
@@ -1165,9 +1192,14 @@ export interface Poser {
1165
1192
  * answers set before the step that poses frame `i` (issue #1416).
1166
1193
  * Optional `times` replaces the uniform grid with increasing absolute times
1167
1194
  * starting at 0 (count = times.length - 1), stepped by adjacent differences.
1168
- * Used by texture-scale to include key times; ordinary renders are unchanged.
1195
+ * Used by texture-scale to include key times, and by a pre-roll to land on
1196
+ * the wrap (`prerollTimes`); ordinary renders are unchanged.
1197
+ * `loop` (issue #1684, `render --preroll`) sets the track looping — the
1198
+ * animation time the track time wrapped by the duration, the physics carried
1199
+ * across every wrap, as a player's looping track carries it; absent, the
1200
+ * track holds at its duration as it always did.
1169
1201
  */
1170
- 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[]): void;
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;
1171
1203
  /** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
1172
1204
  rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
1173
1205
  }
@@ -1242,6 +1274,19 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
1242
1274
  const count = Math.round(animation.duration * fps);
1243
1275
  const draw = drawResolver(poser, opts);
1244
1276
  const frames: Frame[] = [];
1277
+ const preroll = opts?.preroll;
1278
+ if (preroll !== undefined) {
1279
+ // Refused by name at the CLI first (`readPrerollFlag`); here so no other caller walks a pre-roll nobody defined.
1280
+ if (!Number.isInteger(preroll) || preroll < 1) throw new Error(`preroll is ${String(preroll)}, not a whole number of loops at or above 1`);
1281
+ 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
+ // 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);
1284
+ const recorded = (i: number, posed: Posed): void => {
1285
+ if (i >= first) frames.push(frameOf(i - first, (i - first) * step, posed, draw(), opts));
1286
+ };
1287
+ poser.animation(name, opts?.skin, fps, times.length - 1, recorded, undefined, opts?.channels === true ? true : undefined, times, true);
1288
+ return frames;
1289
+ }
1245
1290
  const visit = (i: number, posed: Posed): void => {
1246
1291
  frames.push(frameOf(i, i * step, posed, draw(), opts));
1247
1292
  };
@@ -1252,6 +1297,72 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
1252
1297
  return frames;
1253
1298
  }
1254
1299
 
1300
+ /**
1301
+ * The track time `render --preroll`'s loop recorded starts at (issue #1684): the
1302
+ * end of `preroll` whole loops, `preroll · duration` — where the track has
1303
+ * wrapped `preroll` times and the animation time is 0.
1304
+ *
1305
+ * ⚠️ The product is a float only when `preroll · duration` fits one; where it
1306
+ * does not, it rounds to the nearer float, and that can sit just short of the
1307
+ * wrap — at `duration · 3` for a duration of 1.03, measured — where the
1308
+ * animation time is the duration less an ulp: the pose at the loop's end, not
1309
+ * its start. So a product that lands short is moved to the next float above
1310
+ * it, past the wrap, where the animation time is under one ulp of the product
1311
+ * past 0. No float lies on the wrap there, and this is the one past it.
1312
+ */
1313
+ export function prerollStart(duration: number, preroll: number): number {
1314
+ let start = preroll * duration;
1315
+ while (duration > 0 && start % duration > duration / 2) start = nextFloatAbove(start);
1316
+ return start;
1317
+ }
1318
+
1319
+ /** The float just above a positive finite `x`. */
1320
+ function nextFloatAbove(x: number): number {
1321
+ const box = new Float64Array([x]);
1322
+ const bits = new BigUint64Array(box.buffer);
1323
+ bits[0] += 1n;
1324
+ return box[0];
1325
+ }
1326
+
1327
+ /**
1328
+ * How many points of the render's grid, `j / fps` from `j = 0`, lie strictly
1329
+ * before track time `start` — the steps a pre-roll walks before the one that
1330
+ * lands on `start` (`prerollTimes`).
1331
+ */
1332
+ export function prerollWarmPoints(start: number, fps: number): number {
1333
+ if (!(start > 0)) return 0;
1334
+ let m = Math.max(0, Math.ceil(start * fps) - 1);
1335
+ while ((m + 1) / fps < start) m++;
1336
+ while (m > 0 && m / fps >= start) m--;
1337
+ return m + 1;
1338
+ }
1339
+
1340
+ /**
1341
+ * The track times a pre-roll of `preroll` loops walks, for `Poser.animation`'s
1342
+ * `times` (issue #1684): the render's grid `j / fps` up to the last point
1343
+ * before `prerollStart`, one shorter step onto it, then the loop recorded,
1344
+ * `start + i / fps` for `i = 1..count`. `first` is the index of `start` — the
1345
+ * pose frame 0 is.
1346
+ *
1347
+ * ⭐ **The walk lands on `start` exactly.** Both posers step a track by the
1348
+ * differences of `times` and add them up (`trackTime += delta`), and every
1349
+ * difference before `start` is exact — two floats within a factor of two of
1350
+ * each other subtract without rounding — so each running sum is the time it
1351
+ * steps to, by induction, and the last is `start`. A uniform grid of
1352
+ * `preroll · count` steps instead lands on `preroll · count / fps`, which is
1353
+ * the wrap only where `duration · fps` is whole: elsewhere the loop recorded
1354
+ * started at the wrong phase while frame 0 was filed at time 0.
1355
+ */
1356
+ export function prerollTimes(duration: number, fps: number, preroll: number): { times: number[]; first: number } {
1357
+ const start = prerollStart(duration, preroll);
1358
+ const count = Math.round(duration * fps);
1359
+ const first = prerollWarmPoints(start, fps);
1360
+ const times = Array.from({ length: first }, (_, j) => j / fps);
1361
+ times.push(start);
1362
+ for (let i = 1; i <= count; i++) times.push(start + i / fps);
1363
+ return { times, first };
1364
+ }
1365
+
1255
1366
  /**
1256
1367
  * The setup pose as a single frame — what a skeleton with **no animation at all**
1257
1368
  * looks like.
@@ -1570,6 +1681,8 @@ export interface GeometryFile {
1570
1681
  skin?: string;
1571
1682
  /** The schedule the physics answered (`render --environment`, issue #1416) — `frames.json`'s own `environment`; absent without it. */
1572
1683
  environment?: EnvironmentStep[];
1684
+ /** The whole loops walked before the loop these frames record (`render --preroll`, issue #1684) — `frames.json`'s own `preroll`; absent without it. */
1685
+ preroll?: number;
1573
1686
  fps: number;
1574
1687
  /** The box the PNG frames beside this file were drawn over — `frames.json`'s own `viewport`. */
1575
1688
  viewport: FramesSidecar['viewport'];
@@ -1630,6 +1743,7 @@ export function geometryFileOf(
1630
1743
  viewport: Viewport,
1631
1744
  skin: string | undefined,
1632
1745
  environment?: EnvironmentStep[],
1746
+ preroll?: number,
1633
1747
  ): GeometryFile {
1634
1748
  const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
1635
1749
  if (frame.bones === undefined || frame.attachments === undefined) {
@@ -1654,6 +1768,7 @@ export function geometryFileOf(
1654
1768
  animation,
1655
1769
  ...(skin === undefined ? {} : { skin }),
1656
1770
  ...(environment === undefined ? {} : { environment }),
1771
+ ...(preroll === undefined ? {} : { preroll }),
1657
1772
  fps,
1658
1773
  viewport: sidecarViewport(viewport),
1659
1774
  bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
package/src/sheet.ts CHANGED
@@ -118,6 +118,8 @@ export interface SheetManifest {
118
118
  slots?: string[];
119
119
  hidden?: string[];
120
120
  environment?: EnvironmentStep[];
121
+ /** The whole loops walked before the loop these frames record (`render --preroll`, issue #1684); absent without it. */
122
+ preroll?: number;
121
123
  pinned?: PinnedViewport;
122
124
  fps: number;
123
125
  duration: number;
@@ -182,6 +184,7 @@ export interface BuildSheetSetOptions {
182
184
  skin?: string;
183
185
  subset?: SlotSubset;
184
186
  environment?: EnvironmentStep[];
187
+ preroll?: number;
185
188
  pinned?: PinnedViewport;
186
189
  /** For negative-control testing in `selftest.ts` only: reverse the input order handed to `packAtlas`. */
187
190
  reversePackInputs?: boolean;
@@ -262,9 +265,9 @@ function sanitizeNamespaceSegment(segment: string): string {
262
265
 
263
266
  /**
264
267
  * Build the collision-free frame-key namespace `<rig>/<anim>[/<variant>]` for a
265
- * rendered set. Non-default skin, slot subset, or environment schedule are
266
- * encoded in `<variant>` in fixed order so multiple exports loaded into a shared
267
- * texture cache cannot collide on frame keys.
268
+ * rendered set. Non-default skin, slot subset, environment schedule or pre-roll
269
+ * are encoded in `<variant>` in fixed order so multiple exports loaded into a
270
+ * shared texture cache cannot collide on frame keys.
268
271
  */
269
272
  export function sheetNamespaceOf(opts: {
270
273
  rigName: string;
@@ -272,6 +275,7 @@ export function sheetNamespaceOf(opts: {
272
275
  skin?: string;
273
276
  subset?: SlotSubset;
274
277
  environment?: EnvironmentStep[];
278
+ preroll?: number;
275
279
  }): string {
276
280
  const rig = sanitizeNamespaceSegment(opts.rigName);
277
281
  const anim = sanitizeNamespaceSegment(opts.setName);
@@ -282,6 +286,7 @@ export function sheetNamespaceOf(opts: {
282
286
  const digest = createHash('sha256').update(JSON.stringify(opts.environment)).digest('hex').slice(0, 8);
283
287
  variants.push(`env=${digest}`);
284
288
  }
289
+ if (opts.preroll !== undefined) variants.push(`preroll=${opts.preroll}`);
285
290
  return variants.length === 0 ? `${rig}/${anim}` : `${rig}/${anim}/${variants.join('+')}`;
286
291
  }
287
292
 
@@ -715,6 +720,7 @@ export function buildSheetSet(opts: BuildSheetSetOptions): SheetSetPack {
715
720
  skin: opts.skin,
716
721
  subset: opts.subset,
717
722
  environment: opts.environment,
723
+ preroll: opts.preroll,
718
724
  });
719
725
 
720
726
  const padding = SHEET_PADDING;
@@ -868,6 +874,7 @@ export function buildSheetSet(opts: BuildSheetSetOptions): SheetSetPack {
868
874
  ? { slots: [...opts.subset.names] }
869
875
  : { hidden: [...opts.subset.names] }),
870
876
  ...(opts.environment === undefined ? {} : { environment: opts.environment }),
877
+ ...(opts.preroll === undefined ? {} : { preroll: opts.preroll }),
871
878
  ...(opts.pinned === undefined ? {} : { pinned: opts.pinned }),
872
879
  fps: opts.fps,
873
880
  duration,