@rig-c/core 2.60.0 → 2.61.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -433,7 +433,10 @@ frames. It draws with rigc's own rasteriser (the one `check` measures with), so
433
433
  it needs no browser and no network, and the `frames.json` it leaves beside the
434
434
  directories makes the result a frame set like any other — the world box every
435
435
  frame is a picture of. `--animation <name>` narrows it to one, `--fps` and
436
- `--max` change the rate and the frame size.
436
+ `--max` change the rate and the frame size. A set drawn at any rate but the
437
+ default is written to `<anim>@<fps>fps/` rather than `<anim>/`, so two rates sit
438
+ side by side: `--fps 24` writes `<anim>@24fps/`, and the default 12 writes
439
+ `<anim>/`. Each set's summary line ends with the directory it wrote.
437
440
 
438
441
  > 🚨 **`--skin <name>` if your rig has more than one.** With no `--skin` no skin
439
442
  > is set at all, so every slot resolves through the **default** skin alone — and
@@ -603,7 +606,7 @@ commands take it and what its default is.
603
606
  | `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 |
604
607
  | `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)) |
605
608
  | `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 |
606
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--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 — a reading, not a gate; the exit status never moves. `--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 |
609
+ | `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 — a reading, not a gate; the exit status never moves. `--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 |
607
610
  | `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` |
608
611
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
609
612
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
@@ -614,7 +617,7 @@ commands take it and what its default is.
614
617
  | `bench <rung> --candidate <dir>` | one rung of the benchmark ladder |
615
618
  | `guide <RULE>` / `guide §<n>[.<m>[.<k>]] [--all]` | the guide's row for the rule a failure line names — every `FAIL` line, `vote --record` refusal, `ingest` finding and coded compile error ends with ` (see rigc guide <RULE>)`; only legacy uncoded library errors point at `§5.1` — or a section's heading, own prose and subsection index, read from the installed `docs/AUTHORING.md` when it runs. `--all` includes the subsection bodies; a leaf prints its full text either way. Sizes describe full ledger spans. `--all` with a rule is refused, exit 2; an unknown name is refused with the nearest real ones, exit 1 |
616
619
  | `skills install [--dir …] [--copy]` | links every agent skill the package ships into `.agents/skills` (or `--dir`) as relative symlinks, or copies them with `--copy` — where Codex, Gemini CLI and Antigravity look. A second run has nothing to do; an entry already there that is not this command's is refused by name and nothing is written |
617
- | `review <dir> […]` | the human review of a build, handed to the `rig-review` bin of `@rig-c/review` installed beside the package, with every argument untouched and its exit code returned: `rigc review open <dir>` prints a loopback URL to give a person, `check <dir>` validates the folder it would serve, `feedback <dir>` prints what they left, and `rigc review --help` is review's own usage. rigc does not depend on it: without it the command prints `rigc review: @rig-c/review is not installed — npm i -D @rig-c/review` and exits 2 |
620
+ | `review <dir> […]` | the human review of a build, handed to the `rig-review` bin of `@rig-c/review` installed beside the package, with every argument untouched and its exit code returned: `rigc review open <dir>` prints a loopback URL to give a person, `check <dir>` validates the folder it would serve, `feedback <dir>` prints what they left, and `rigc review --help` is review's own usage. rigc does not depend on it: without it the command prints `rigc review: @rig-c/review is not installed — npm i -D @rig-c/review` and exits 2. An empty argument (`""`) is handed over only where the running Bun keeps one when it starts a process — Bun 1.2.0 drops it, measured by a probe rather than by version — and is otherwise refused by name, `rigc review: argument N is empty, and Bun <version>, the Bun running this, drops an empty argument when it starts a process — …`, exit 2 |
618
621
 
619
622
  `diff`, `bonedist`, `check` and `bench` measure against something you were given; the
620
623
  first three work on any reference you have, and `bench` is a repository workflow that needs a clone
package/docs/AUTHORING.md CHANGED
@@ -1242,7 +1242,11 @@ bun cli.ts pose --images path/to/parts --frame poseA.png [--out pose.json]
1242
1242
  gate can know you did not mean them. `render` writes
1243
1243
  `render/<animation>/f0000.png…` with a `contact.png` grid of **every** frame
1244
1244
  beside them (open that one first — spacing is a comparison across frames) and a
1245
- `frames.json` sidecar naming the world box they are pictures of. `contact.png`
1245
+ `frames.json` sidecar naming the world box they are pictures of. It also says
1246
+ whether the physics ran: under each set's line, `physics: N constraint(s)
1247
+ stepped at <fps> fps`, and the same two figures on that set in `frames.json` —
1248
+ or, once, `physics: none in this rig` for a rig that declares no physics
1249
+ constraint (§8.4). `contact.png`
1246
1250
  is for spacing across frames; a defect is read on **one frame at full size**,
1247
1251
  because a tile is too small to say which part a pixel belongs to. When a frame
1248
1252
  looks wrong, render it again with `--hide <slot>` — the **largest attachment**
@@ -6843,6 +6847,14 @@ it had nothing to step (`nanStepping=skipped`). What it does not see is a skin o
6843
6847
  than the default, which A10 does not pose. The sentence, and where to look for the
6844
6848
  number, is under *nothing to draw* in §3.4.
6845
6849
 
6850
+ ⌨️ **An invocation missing a flag is one line, before any of these.** `build`,
6851
+ `explain` and `validate` given a cut spelled out with `--rig`, `--motion` or
6852
+ `--out` absent print `rigc: <command> needs --<flag> <value> (or --cut <name>
6853
+ --cuts <cuts.json>)` on stderr and exit 2 — the line alone, with no command
6854
+ catalogue under it. An input flag that names a **directory** is a compile error
6855
+ with a remedy in it: `CE_COMPILE_JSON_READ_FAILED` says what the flag wants and
6856
+ names the one file there that reads as it (§5.1.2). It is never read as that file.
6857
+
6846
6858
  ### 5.1 Compile errors — before the gate
6847
6859
 
6848
6860
  A `CompileError` names the object and the field, and nothing is written. Its line
@@ -6919,7 +6931,7 @@ retain the minimal inputs and report that code rather than inventing author data
6919
6931
  | <a id="CE_COMPILE_RGBA_CHANNEL_COUNT"></a>`CE_COMPILE_RGBA_CHANNEL_COUNT` | “rgba value needs 4 channels, got &lt;value&gt;” | Supply four rgba channels. |
6920
6932
  | <a id="CE_COMPILE_RGBA2_CHANNEL_COUNT"></a>`CE_COMPILE_RGBA2_CHANNEL_COUNT` | “rgba2 value needs 7 channels, got &lt;value&gt;” | Supply seven rgba2 channels. |
6921
6933
  | <a id="CE_COMPILE_COLOUR_CHANNEL_COUNT"></a>`CE_COMPILE_COLOUR_CHANNEL_COUNT` | “&lt;value&gt; value needs &lt;value&gt; channel[ / s], got &lt;value&gt;” | Supply the channel count named for this colour shape. |
6922
- | <a id="CE_COMPILE_JSON_READ_FAILED"></a>`CE_COMPILE_JSON_READ_FAILED` | “cannot read &lt;value&gt;: &lt;value&gt;”<br>Also: “&lt;value&gt; (line &lt;value&gt;, column &lt;value&gt;)” | Fix the named JSON file's path, permissions or JSON syntax. |
6934
+ | <a id="CE_COMPILE_JSON_READ_FAILED"></a>`CE_COMPILE_JSON_READ_FAILED` | “cannot read &lt;value&gt;: &lt;value&gt;”<br>Also: “&lt;value&gt; (line &lt;value&gt;, column &lt;value&gt;)”<br>Also, when `--rig`, `--motion` or `--manifest` names a directory: “&lt;value&gt; names a directory; it wants &lt;value&gt; — &lt;value&gt; exists there” · “… — &lt;value&gt; holds no .json file” · “… — &lt;value&gt; holds &lt;value&gt; .json file(s) and none reads as one: &lt;value&gt;” · “… — &lt;value&gt; .json files in &lt;value&gt; read as one, so name it: &lt;value&gt;”, a list cut at eight names with “, … &lt;value&gt; in all” | Fix the named JSON file's path, permissions or JSON syntax. For a directory, name the file the line names — the one `.json` there that reads as that input (a rig spec through the rig spec's parser, a motion spec through the motion spec's; for `--manifest`, any `.json`) — or pick one of the files it lists. A directory is never read as its file: rigc compiles what it was given. |
6923
6935
  | <a id="CE_COMPILE_IMAGE_MISSING"></a>`CE_COMPILE_IMAGE_MISSING` | “image "&lt;value&gt;" is not on disk at &lt;value&gt;”<br>Also: “&lt;value&gt;.png” | Supply the PNG at the named images path or correct the image name. |
6924
6936
  | <a id="CE_COMPILE_IMAGE_PNG_INVALID"></a>`CE_COMPILE_IMAGE_PNG_INVALID` | “&lt;value&gt; is &lt;value&gt;, not a PNG: its first &lt;value&gt; byte(s) are &lt;value&gt;, where a PNG's first 8 are &lt;value&gt;&lt;value&gt;. rigc reads PNG and nothing else — its page-size and alpha readers, its renderer and its region lift all decode PNG, and it links no decoder for any other format — so nothing in this file was measured. Re-export it as PNG”<br>“&lt;value&gt; is a PNG whose IHDR chunk declares &lt;value&gt; byte(s) of data, where the format's is 13”<br>“&lt;value&gt; is a PNG whose IHDR chunk states its size as &lt;value&gt;x&lt;value&gt;: &lt;value&gt; 0, where the format requires each dimension to be at least 1, so the file describes no texel and nothing in it can be measured or drawn. Re-export it”<br>“&lt;value&gt; is a PNG whose first chunk is &lt;value&gt; rather than the IHDR the format requires first”<br>“&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and &lt;value&gt;, so no reader can decode it. Re-export or re-copy the file whole”<br>“&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and ends &lt;value&gt; byte(s) into the 8-byte PNG signature, so no reader can decode it. Re-export or re-copy the file whole”<br>“&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and ends after the signature with no IEND chunk, so no reader can decode it. Re-export or re-copy the file whole”<br>“&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and its &lt;value&gt; chunk at byte &lt;value&gt; declares &lt;value&gt; byte(s) of data, which with its CRC runs to byte &lt;value&gt;, so no reader can decode it. Re-export or re-copy the file whole”<br>“&lt;value&gt; is a truncated PNG: it is &lt;value&gt; byte(s) long and {ends after &lt;value&gt; with no IEND chunk / ends &lt;value&gt; byte(s) into the header of the chunk after &lt;value&gt;}, so no reader can decode it. Re-export or re-copy the file whole”<br>“&lt;value&gt; is empty (0 bytes), and a PNG's first 8 bytes are &lt;value&gt;”<br>“[; its name ends in .png, and it is the bytes that decide what a file is / ]”<br>“[its width and its height are / [its width is / its height is]]”<br>“a &lt;value&gt; image”<br>“a WebP image (a RIFF/WEBP container whose first chunk is &lt;value&gt;)”<br>“image "&lt;value&gt;": &lt;value&gt;”<br>“in no image format rigc recognises by its signature (it names &lt;value&gt; when it meets them)”<br>Here the values named in the message include: “KTX2”, “GIF”, “JPEG”. | Supply a readable PNG with the required header and dimensions at the named path. |
6925
6937
  | <a id="CE_COMPILE_ATLAS_INPUT_MISSING"></a>`CE_COMPILE_ATLAS_INPUT_MISSING` | “--atlas-in names &lt;value&gt;, which is not on disk” | Correct --atlas-in or supply the atlas file at that path. |
@@ -7370,7 +7382,7 @@ retain the minimal inputs and report that code rather than inventing author data
7370
7382
  | <a id="CE_STOP_LINK_UNDRIVEN"></a>`CE_STOP_LINK_UNDRIVEN` | “&lt;stop.who&gt;: links[&lt;k&gt;] "&lt;link&gt;" is written by no constraint of this rig spec — a stop holds a link something swings (a physics, ik, transform or path constraint in "constraints"), and its copy and clamp run right after that. A physics constraint in the motion spec's table is emitted after every rig constraint, past the clamp; declare it here” | Declare the swinging physics, ik, transform or path constraint in the rig spec's constraints before its stop clamp. |
7371
7383
  | <a id="CE_STOP_LINK_PARENT_CHAIN"></a>`CE_STOP_LINK_PARENT_CHAIN` | “&lt;stop.who&gt;: links[&lt;k&gt;] "&lt;links[k]&gt;" does not hang below "&lt;names.drawn[k - 1]&gt;", the drawn link of links[&lt;k - 1&gt;] — a chain's next link hangs from the drawn link above it, so the clamp that holds that one carries this one; parent it there” | Parent each lower link under the preceding generated drawn link. |
7372
7384
  | <a id="CE_STOP_AGAINST_MOVES_WITH_STOP"></a>`CE_STOP_AGAINST_MOVES_WITH_STOP` | “&lt;stop.who&gt;: "against" "&lt;stop.against&gt;" hangs below "&lt;under&gt;", which the stop moves — the point would swing with the chain it holds off; name the leg” | Name an against bone outside the subtree moved by the stop. |
7373
- | <a id="CE_STOP_LOCAL_BOUND_RANGE"></a>`CE_STOP_LOCAL_BOUND_RANGE` | “&lt;stop.who&gt;: "&lt;field&gt;&lt;end&gt;" &lt;range[end]&gt; puts &lt;what&gt;'s bound at &lt;value&gt;°, past ±&lt;BOUND&gt;° — the clamp reads a local rotation in (-180, 180], so a bound past it is never reached and the range it states is not the one that holds”<br>On the first link it goes on: “. "&lt;stop.links[0]&gt;" hangs at &lt;at.hang&gt;° from its root at [&lt;at.root&gt;], and "&lt;stop.names.point&gt;" ("at" [&lt;stop.at&gt;] on "&lt;stop.against&gt;") sits at [&lt;at.point&gt;] — Spine world, y up, at the setup pose — so the aim from that root to the point lies &lt;off&gt;° off the hang (rest &lt;rest&gt;°): \|rest\| &lt;off&gt;° + "&lt;end&gt;" &lt;e&gt;° is &lt;sum&gt;°”, then one of “, above &lt;BOUND&gt;°. Move "&lt;stop.names.point&gt;" to within &lt;BOUND − e&gt;° (&lt;BOUND&gt;° − "&lt;end&gt;") of the direction "&lt;stop.links[0]&gt;" hangs in from its root, &lt;at.hang&gt;°, or state "&lt;end&gt;" at most &lt;BOUND − off&gt;°”, “, above &lt;BOUND&gt;°, and no point lowers it: \|rest\| is above 0° for every point a stop accepts. State "&lt;end&gt;" below &lt;BOUND&gt;°”, “, below -&lt;BOUND&gt;°. State "&lt;end&gt;" at least &lt;−BOUND − off&gt;°”<br>Here the values named in the message include: “inward”, “outward”. | On the first link the bound is \|rest\| + the end, where rest is the angle from the link's hang to the aim at `<chain>_touch`, both read from the link's root: move `<chain>_touch` (its `at` on `against`) to within 180° − `outward` of the direction the link hangs in from its root — on a link hanging straight down with `outward` at most 90°, any point below the root — or lower `outward` to at most 180° − \|rest\|. A leg that rises from behind the cloth, as on a prone figure, puts a point at its far end above the root; put it at the knee. On a lower link, bring `lower`'s ends within ±180° of the link's own rest bend. |
7385
+ | <a id="CE_STOP_LOCAL_BOUND_RANGE"></a>`CE_STOP_LOCAL_BOUND_RANGE` | “&lt;stop.who&gt;: "&lt;field&gt;&lt;end&gt;" &lt;range[end]&gt; puts &lt;what&gt;'s bound at &lt;value&gt;°, past ±&lt;BOUND&gt;° — the clamp reads a local rotation in (-180, 180], so a bound past it is never reached and the range it states is not the one that holds”<br>On the first link it goes on: “. "&lt;stop.links[0]&gt;" hangs at &lt;at.hang&gt;° from its root at [&lt;at.root&gt;], and "&lt;stop.names.point&gt;" ("at" [&lt;stop.at&gt;] on "&lt;stop.against&gt;") sits at [&lt;at.point&gt;] — Spine world, y up, at the setup pose — so the aim from that root to the point lies &lt;off&gt;° off the hang (rest &lt;rest&gt;°): \|rest\| &lt;off&gt;° + "&lt;end&gt;" &lt;e&gt;° is &lt;sum&gt;°”, then one of “, above &lt;BOUND&gt;°. Move "&lt;stop.names.point&gt;" to within &lt;BOUND − e&gt;° (&lt;BOUND&gt;° − "&lt;end&gt;") of the direction "&lt;stop.links[0]&gt;" hangs in from its root, &lt;at.hang&gt;°, or state "&lt;end&gt;" at most &lt;BOUND − off&gt;°”, “, above &lt;BOUND&gt;°, and no point lowers it: \|rest\| is above 0° for every point a stop accepts. State "&lt;end&gt;" below &lt;BOUND&gt;°”, “, below -&lt;BOUND&gt;°. State "&lt;end&gt;" at least &lt;−BOUND − off&gt;°”<br>Here the values named in the message include: “inward”, “outward”. | On the first link the bound is \|rest\| + the end, where rest is the angle from the link's hang to the aim at `<chain>_touch`, both read from the link's root: move `<chain>_touch` (its `at` on `against`) to within 180° − `outward` of the direction the link hangs in from its root — on a link hanging straight down with `outward` at most 90°, any point below the root — or lower `outward` to at most 180° − \|rest\|. A leg that rises from behind the cloth, as on a prone figure, puts a point at its far end above the root; put it at the knee. On a lower link, bring `lower`'s ends within ±180° of the link's own rest bend. The values you stated (`inward`, `outward`, `at`) are echoed as written; every figure the expansion derived prints to three decimals, a figure past ±180° rounded away from zero so it never reads as on the bound, and a remedy (`at most`, `at least`, `within`) rounded toward zero, so stating the printed value is accepted. |
7374
7386
  | <a id="CE_STOP_DEPENDENCY_ORDER"></a>`CE_STOP_DEPENDENCY_ORDER` | “&lt;who&gt;: "&lt;tip&gt;" hangs below "&lt;name&gt;", which a later stop generates — declare that stop first, so its angles are known when this one is read”<br>Also: “&lt;value&gt;: stop "&lt;value&gt;"” | Declare the stop generating the parent before the stop depending on it. |
7375
7387
  | <a id="CE_STOP_ANCESTOR_MANIFEST_POSITION"></a>`CE_STOP_ANCESTOR_MANIFEST_POSITION` | “&lt;who&gt;: "&lt;name&gt;" takes its place from the manifest ("from"), and a stop's angles are read off the setup pose this rig spec states — state its x, y and rotation”<br>Also: “&lt;value&gt;: stop "&lt;value&gt;"” | State x, y and rotation for the named setup-pose ancestor instead of from. |
7376
7388
  | <a id="CE_STOP_FRAME_PARENT_TRANSFORM"></a>`CE_STOP_FRAME_PARENT_TRANSFORM` | “&lt;who&gt;: "&lt;parent&gt;", where the frame hangs, is scaled unevenly, sheared or reflected at the setup pose — the frame's angles add to its parent's only through a rotation and an even scale, and inward is a turn's sense”<br>Also: “&lt;value&gt;: stop "&lt;value&gt;"” | Use a frame parent with rotation and even positive scale, without shear or reflection. |
@@ -8496,6 +8508,34 @@ from its own frame and no earlier (`RE02`); and plants on the core the readings
8496
8508
  mapping rejects — the schedule ignored, its y read up, each change a frame late or
8497
8509
  early — each red against spine-core (`RE03`).
8498
8510
 
8511
+ **Whether the physics ran.** Every render — with this flag or without — says, under
8512
+ each set's line, how many physics constraints the poser stepped on at least one of
8513
+ its frames, and at what rate:
8514
+
8515
+ ```text
8516
+ .. idle 25 frame(s), 2.000s + contact.png -> render/idle
8517
+ .. physics: 3 constraint(s) stepped at 12 fps
8518
+ ```
8519
+
8520
+ and `frames.json` carries the same two figures on that set, `"physics": { "stepped":
8521
+ 3, "fps": 12 }`. The rate is the set's own: one physics update a frame. The count is
8522
+ read off the posed state, not off the declaration: a constraint counts when, after
8523
+ a step that moved the skeleton's clock, its own clock reads the skeleton's — which
8524
+ the physics update sets and every way of not running leaves behind. So a constraint
8525
+ that is inactive on every frame, or at a mix of 0, is not counted, and the line names
8526
+ it: `physics: 0 constraint(s) stepped at 12 fps — 1 of the 1 declared took none:
8527
+ "iris_settle" (inactive, or a mix of 0, on every frame)`; a set of one pose — an
8528
+ animation of no length, or the setup pose of a skeleton with none — takes no step
8529
+ and says so. A rig that declares no physics constraint prints `physics: none in this
8530
+ rig — it declares no physics constraint, so no frame steps one` once, before the
8531
+ sets, and its `frames.json` carries no `physics` field, as every frame set written
8532
+ before the field existed carries none. This is what tells a reading — a fold count
8533
+ from `--folds`, say (§8.3) — taken with the physics from one taken without. Both
8534
+ posers read the same constraints on every frame, and `cli.ts`, `cli.ts --poser
8535
+ spine` and `cli_core.ts` write the same files (`PH01`, `PH02`); the step removed —
8536
+ on the core, or spine-core's `PhysicsConstraint.update` made a no-op — reads 0
8537
+ (`PH03`), and a constraint at mix 0 is named (`PH04`).
8538
+
8499
8539
  **The files.** `frames.json` and each `geometry.json` record the schedule as
8500
8540
  `environment`, its steps as read, `t`, `wind`, `gravity` in that order. The
8501
8541
  framing reads it too: a gust can carry a coat past the box a still rig fills, so
package/docs/INGEST.md CHANGED
@@ -191,6 +191,7 @@ rigc render
191
191
  .. atlas …/examples/spineboy/export/spineboy.atlas
192
192
  .. poser spine-core — no skeleton.model.json beside …/examples/spineboy/export/spineboy-pro.json — a Spine export, not a rigc build
193
193
  .. 200x186px at 8 fps, 1 set(s) -> …/render/sb
194
+ .. physics: none in this rig — it declares no physics constraint, so no frame steps one
194
195
  .. walk 9 frame(s), 1.000s + contact.png -> …/render/sb/walk@8fps
195
196
  rigc: wrote …/render/sb/frames.json
196
197
  ```
package/docs/MOTION.md CHANGED
@@ -1067,6 +1067,7 @@ rigc render --candidate spine --fps 24 --max 200
1067
1067
 
1068
1068
  ```
1069
1069
  .. 134x200px at 24 fps, 1 set(s) -> …/semaphore/render
1070
+ .. physics: none in this rig — it declares no physics constraint, so no frame steps one
1070
1071
  .. raise 14 frame(s), 0.542s + contact.png -> …/semaphore/render/raise@24fps
1071
1072
  ```
1072
1073
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.60.0",
3
+ "version": "2.61.1",
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": {
@@ -736,6 +736,8 @@ export function cmdRender(flags: Record<string, string>): void {
736
736
  const only = readAnimationFlag(flags, [...facts.animations]);
737
737
  const skin = readSkinFlag(flags, [...facts.skins]);
738
738
  const subset = readSlotSubsetFlags(flags, facts, skin);
739
+ // The physics constraints the skeleton declares, off the Spine file both posers read (issue #1467).
740
+ const physicsDeclared = environmentResponse(skeletonText).declared;
739
741
  const passPairs = readPassThroughFlag(passThrough, facts);
740
742
  const seamPairs = readSeamFlag(seam, facts);
741
743
  // The angle reading (issue #1414) reads bones, which a subset of the drawn slots does not change, so it takes --slot/--hide.
@@ -810,7 +812,14 @@ export function cmdRender(flags: Record<string, string>): void {
810
812
  // The bones ride along for --geometry and --angle-range, the whole attachments for --geometry and the two readings off them.
811
813
  const wantsBones = geometry || anglePairs !== undefined;
812
814
  const wantsGeometry = geometry || folds || passPairs !== undefined || seamPairs !== undefined;
813
- const sampling = !wantsBones && !wantsGeometry ? pose : { ...pose, ...(wantsBones ? { bones: true } : {}), ...(wantsGeometry ? { geometry: true } : {}) };
815
+ // The physics constraints each frame's step stepped ride along whenever the rig declares one (issue #1467): the
816
+ // `physics:` line and frames.json's `physics` field are read off them.
817
+ const sampling = {
818
+ ...pose,
819
+ ...(wantsBones ? { bones: true } : {}),
820
+ ...(wantsGeometry ? { geometry: true } : {}),
821
+ ...(physicsDeclared.length > 0 ? { physics: true } : {}),
822
+ };
814
823
  const sampled: Map<string, Frame[]> =
815
824
  only === undefined
816
825
  ? sampleAll(poser, fps, sampling)
@@ -916,6 +925,8 @@ export function cmdRender(flags: Record<string, string>): void {
916
925
  );
917
926
  }
918
927
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
928
+ // Whether physics ran (issue #1467): said once for a rig with nothing to step, and per set below for one with.
929
+ if (physicsDeclared.length === 0) console.log(` .. ${PHYSICS_NONE_LINE}`);
919
930
  if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
920
931
  // A given box (issue #1414): said with the box it replaced, and every slot it leaves part of out, by set and frame.
921
932
  const outside = given === undefined ? [] : outsideViewport(sampled, viewport);
@@ -959,6 +970,7 @@ export function cmdRender(flags: Record<string, string>): void {
959
970
  const seamReading = seamReadings.get(name);
960
971
  if (seamReading !== undefined) writeFileSync(join(dir, SEAM_FILE), seamText(seamReading));
961
972
  const duration = frames[frames.length - 1].time;
973
+ const physics = physicsDeclared.length === 0 ? undefined : physicsSteppedOf(frames, fps);
962
974
  sets.push({
963
975
  dir: dirName,
964
976
  animation: name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name,
@@ -967,6 +979,7 @@ export function cmdRender(flags: Record<string, string>): void {
967
979
  written: frames.length,
968
980
  stride: 1,
969
981
  duration,
982
+ ...(physics === undefined ? {} : { physics: { stepped: physics.stepped.length, fps } }),
970
983
  });
971
984
  const how = frames.length === 1 ? 'a single pose' : `${duration.toFixed(3)}s`;
972
985
  const folding = foldReading?.meshes.filter((m) => m.worst !== null).length ?? 0;
@@ -977,6 +990,7 @@ export function cmdRender(flags: Record<string, string>): void {
977
990
  const seamNote = seamReading === undefined ? '' : ` + ${SEAM_FILE} (${opening} of ${seamReading.pairs.length} pair(s) open)`;
978
991
  const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}`;
979
992
  console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
993
+ if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length)}`);
980
994
  for (const mesh of foldReading?.meshes ?? []) {
981
995
  const key = `${mesh.slot}\u0000${mesh.attachment}`;
982
996
  const fault = faultsSaid.has(key) ? null : setupFaultLine(mesh);
@@ -1022,6 +1036,28 @@ export function cmdRender(flags: Record<string, string>): void {
1022
1036
  console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
1023
1037
  }
1024
1038
 
1039
+ /** `render`'s line for a rig that declares no physics constraint (issue #1467): nothing was stepped because nothing could be. */
1040
+ export const PHYSICS_NONE_LINE = 'physics: none in this rig — it declares no physics constraint, so no frame steps one';
1041
+
1042
+ /** The physics constraints any frame of a set stepped (issue #1467), by name in code-unit order. */
1043
+ export function physicsSteppedOf(frames: readonly Frame[], fps: number): { stepped: string[]; fps: number } {
1044
+ const names = new Set<string>();
1045
+ for (const frame of frames) for (const name of frame.physics ?? []) names.add(name);
1046
+ return { stepped: [...names].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0)), fps };
1047
+ }
1048
+
1049
+ /**
1050
+ * `render`'s `physics:` line for one set (issue #1467): how many constraints were stepped at the set's rate, and — when
1051
+ * fewer than the rig declares — the ones that took no step, by name, with why a constraint takes none.
1052
+ */
1053
+ export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number): string {
1054
+ const head = `physics: ${stepped.length} constraint(s) stepped at ${fps} fps`;
1055
+ const idle = declared.filter((name) => !stepped.includes(name));
1056
+ if (idle.length === 0) return head;
1057
+ const why = frames === 1 ? 'a single pose takes no step' : 'inactive, or a mix of 0, on every frame';
1058
+ return `${head} — ${idle.length} of the ${declared.length} declared took none: ${idle.map((n) => JSON.stringify(n)).join(', ')} (${why})`;
1059
+ }
1060
+
1025
1061
  /** Where `inspect` writes when `--out` is not given, and the name it takes inside a directory `--out` names. */
1026
1062
  export const DEFAULT_INSPECT_OUT = 'inspect.png';
1027
1063
 
@@ -1351,7 +1387,7 @@ export function cmdExplain(flags: Record<string, string>): void {
1351
1387
  const leverFold = flags['lever-fold'] === undefined ? null : Number(flags['lever-fold']);
1352
1388
  const leverWhy = leverFold === null ? null : leverFractionWhy(leverFold);
1353
1389
  if (leverWhy !== null) throw new UsageError(`--lever-fold ${JSON.stringify(flags['lever-fold'])}: ${leverWhy}`);
1354
- const { label, opts } = resolveCut(flags);
1390
+ const { label, opts } = resolveCut(flags, 'explain');
1355
1391
  console.log(`rigc explain ${label}`);
1356
1392
  // See the identical pair of lines in `cmdBuild` for why both paths are named
1357
1393
  // here rather than only the one the header's `label` happens to carry.
@@ -2203,6 +2239,39 @@ function resolveDelegate(name: string): string | null {
2203
2239
  return null;
2204
2240
  }
2205
2241
 
2242
+ /**
2243
+ * Whether the Bun running this hands an empty argument to a process it starts
2244
+ * (issue #1465). Bun 1.2.0 — the declared minimum — does not: `["a", "", "b"]`
2245
+ * reaches the child as `["a", "b"]`, through `spawnSync` and `Bun.spawnSync`
2246
+ * alike and whatever the child is; 1.4.2 hands it over. That is measured here
2247
+ * rather than read off a version number, by starting this Bun twice with
2248
+ * `-e` and comparing how many arguments arrived: `[pad, "x"]` and
2249
+ * `[pad, "", "x"]` differ by one exactly where the empty one survives. The pad
2250
+ * is there because 1.2.0's `-e` also swallows the first argument after the
2251
+ * script; it is the same on both runs, so the difference is the empty one's.
2252
+ */
2253
+ export function spawnKeepsEmptyArguments(): boolean {
2254
+ const count = (args: string[]): string =>
2255
+ spawnSync(process.execPath, ['-e', 'process.stdout.write(String(process.argv.length))', ...args], { encoding: 'utf8' }).stdout;
2256
+ return Number(count(['pad', '', 'x'])) === Number(count(['pad', 'x'])) + 1;
2257
+ }
2258
+
2259
+ /**
2260
+ * The refusal for a delegated command line this Bun cannot hand over whole, or
2261
+ * `null` (issue #1465): an empty argument, under a Bun that drops one, would
2262
+ * reach the package with the others and nothing would say so. `keeps` is asked
2263
+ * only when there is an empty argument, so a command line without one never
2264
+ * starts the probe.
2265
+ */
2266
+ export function emptyArgumentRefusal(pkg: string, raw: readonly string[], keeps: () => boolean, bun: string): string | null {
2267
+ const at = raw.indexOf('');
2268
+ if (at < 0 || keeps()) return null;
2269
+ return (
2270
+ `argument ${at + 1} is empty, and Bun ${bun}, the Bun running this, drops an empty argument when it starts a process — ` +
2271
+ `${pkg} would receive the others without it, and nothing would say so. Give it a value, or run rigc under a Bun that keeps it`
2272
+ );
2273
+ }
2274
+
2206
2275
  /**
2207
2276
  * A delegating command's body (`CommandDoc.delegate`, issue #1457): resolve the
2208
2277
  * package's bin and run it under the Bun running this — the bin is a Bun script
@@ -2225,6 +2294,8 @@ function cmdDelegate(command: string, raw: readonly string[]): void {
2225
2294
  2,
2226
2295
  );
2227
2296
  }
2297
+ const dropped = emptyArgumentRefusal(delegate.package, raw, spawnKeepsEmptyArguments, process.versions.bun ?? 'unknown');
2298
+ if (dropped !== null) throw new DelegateError(dropped, 2);
2228
2299
  const ran = spawnSync(process.execPath, [bin, ...raw], { stdio: 'inherit' });
2229
2300
  // ⚠️ The exit and the signal are read before `error`: Bun 1.2.0 — the declared minimum — sets `error` on every
2230
2301
  // child that exits non-zero, so reading it first refused review's own exit 7 as "could not run". `error` is a
package/src/cli/shared.ts CHANGED
@@ -94,6 +94,19 @@ export type CutTable = Record<string, CutEntry>;
94
94
 
95
95
  export class UsageError extends Error {}
96
96
 
97
+ /**
98
+ * A usage error that is one required flag missing from a spelled-out cut —
99
+ * printed as its one line and exit 2, without the usage block under it (issue
100
+ * #1466). The catalogue under `--out is required` buried the only line that
101
+ * said what to add, which is `PoseError`'s reason for printing alone. Still a
102
+ * `UsageError`, so every reader that asks whether an invocation was refused
103
+ * reads it as one.
104
+ */
105
+ export class MissingFlagError extends UsageError {}
106
+
107
+ /** The `<value>` each flag of a spelled-out cut takes, for the line that names a missing one. */
108
+ const CUT_FLAG_VALUES = { rig: '<rig.json>', motion: '<motion.json>', out: '<dir>' } as const;
109
+
97
110
  /**
98
111
  * `explain` refusing a pair it cannot pose — a usage error in kind, printed
99
112
  * without the usage block for `PoseError`'s and `IngestError`'s reason: the
@@ -264,7 +277,7 @@ function entryToOptions(dir: string, name: string, entry: CutEntry): CompileOpti
264
277
  * Resolve the cut a command was pointed at, either spelled out on the command
265
278
  * line or looked up by name in a cuts.json.
266
279
  */
267
- export function resolveCut(flags: Record<string, string>): { label: string; opts: CompileOptions } {
280
+ export function resolveCut(flags: Record<string, string>, command: 'build' | 'explain' | 'validate'): { label: string; opts: CompileOptions } {
268
281
  const explicit =
269
282
  flags.rig !== undefined || flags.manifest !== undefined || flags.motion !== undefined || flags.out !== undefined;
270
283
  if (explicit) {
@@ -272,7 +285,7 @@ export function resolveCut(flags: Record<string, string>): { label: string; opts
272
285
  throw new UsageError('--rig/--motion/--out and --cut/--cuts are two ways to say the same thing; pick one');
273
286
  }
274
287
  for (const key of ['rig', 'motion', 'out'] as const) {
275
- if (flags[key] === undefined) throw new UsageError(`--${key} is required when the cut is spelled out`);
288
+ if (flags[key] === undefined) throw new MissingFlagError(`${command} needs --${key} ${CUT_FLAG_VALUES[key]} (or --cut <name> --cuts <cuts.json>)`);
276
289
  }
277
290
  const opts: CompileOptions = {
278
291
  rigPath: resolve(flags.rig),
@@ -2291,7 +2304,7 @@ function pageRectangle(region: AtlasRegion): string {
2291
2304
  * alone (`./core_commands.ts`). Moved here unchanged from `cmdBuild`.
2292
2305
  */
2293
2306
  export function readBuildInvocation(flags: Record<string, string>): { label: string; opts: CompileOptions; profile: AssertionProfile; packing: boolean } {
2294
- const { label, opts } = resolveCut(flags);
2307
+ const { label, opts } = resolveCut(flags, 'build');
2295
2308
  const profile = readProfile(flags);
2296
2309
  const packing = flags.pack !== undefined;
2297
2310
  // Two combinations are refused rather than silently resolved, because in each
@@ -3001,6 +3014,10 @@ export function runCli(entry: CliEntry, argv: readonly string[]): void {
3001
3014
  * (`CliEntry.refusals`). Anything else is not a refusal and is thrown on.
3002
3015
  */
3003
3016
  function refuse(err: unknown, command: string | undefined, USAGE: string, entry: CliEntry): never {
3017
+ if (err instanceof MissingFlagError) {
3018
+ console.error(`rigc: ${err.message}`);
3019
+ process.exit(2);
3020
+ }
3004
3021
  if (err instanceof UsageError) {
3005
3022
  console.error(`rigc: ${err.message}\n\n${USAGE}`);
3006
3023
  process.exit(2);
@@ -76,7 +76,7 @@ export function cmdValidate(flags: Record<string, string>, positional: string[])
76
76
  // a directory alone cannot supply — and the report says which it had.
77
77
  const named = flags.cut !== undefined || flags.rig !== undefined;
78
78
  const profile = readProfile(flags);
79
- const derivedOpts = named ? resolveCut(flags).opts : null;
79
+ const derivedOpts = named ? resolveCut(flags, 'validate').opts : null;
80
80
  // Both files there before the first line, from the one statement of a build (issue #1046): a directory without them
81
81
  // read the skeleton unguarded and died on an ENOENT and a stack.
82
82
  const { skeletonPath, atlasPath } = spinePairOf(resolveBuild(derivedOpts ? derivedOpts.outDir : (positional[0] ?? '.'), flags.atlas));
package/src/compile.ts CHANGED
@@ -18,7 +18,7 @@
18
18
  * the two emits byte for byte (assertion A18).
19
19
  */
20
20
  import { basename, dirname, join, relative, resolve } from 'node:path';
21
- import { existsSync, readFileSync } from 'node:fs';
21
+ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
22
22
  import { readPngHeader, readPngInfo } from './png.ts';
23
23
  import {
24
24
  DEPTH_TONE_IDENTITY,
@@ -1649,8 +1649,62 @@ function easingCurve(
1649
1649
  // inputs
1650
1650
  // ---------------------------------------------------------------------------
1651
1651
 
1652
- function readJson<T>(path: string): T {
1652
+ /**
1653
+ * Which input a JSON path was given as, for the refusal a directory gets: the
1654
+ * flag that named it, the file it wants, and whether a file's contents read as
1655
+ * one (`null` where no parser exists here — the manifest is cast, not parsed).
1656
+ */
1657
+ interface JsonInputRole {
1658
+ flag: '--rig' | '--motion' | '--manifest';
1659
+ wants: string;
1660
+ reads: ((raw: unknown, where: string) => unknown) | null;
1661
+ }
1662
+
1663
+ const RIG_INPUT: JsonInputRole = { flag: '--rig', wants: 'the rig spec file', reads: parseRigSpec };
1664
+ const MOTION_INPUT: JsonInputRole = { flag: '--motion', wants: 'the motion spec file', reads: parseMotionSpec };
1665
+ const MANIFEST_INPUT: JsonInputRole = { flag: '--manifest', wants: 'the cut manifest file', reads: null };
1666
+
1667
+ /** The most `.json` names a directory refusal lists before it says how many there are. */
1668
+ const LISTED_JSON_NAMES = 8;
1669
+
1670
+ /**
1671
+ * The refusal for an input flag that names a directory (issue #1466): what the
1672
+ * flag wants, and what the directory holds — the one file there that reads as
1673
+ * that input, named by its path, or every `.json` there when that is not
1674
+ * exactly one. Nothing is accepted on the strength of it: the caller is told
1675
+ * the path, and the compile still reads only what it was given. The names are
1676
+ * sorted, so the sentence is the same on every machine.
1677
+ */
1678
+ function directoryInputRefusal(dir: string, role: JsonInputRole): string {
1679
+ const head = `${role.flag} names a directory; it wants ${role.wants} —`;
1680
+ const names = readdirSync(dir)
1681
+ .filter((name) => name.endsWith('.json') && statSync(join(dir, name)).isFile())
1682
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
1683
+ if (names.length === 0) return `${head} ${dir} holds no .json file`;
1684
+ const reading = names.filter((name) => {
1685
+ if (role.reads === null) return true;
1686
+ const file = join(dir, name);
1687
+ try {
1688
+ role.reads(parseJsonWithPosition(readFileSync(file, 'utf8')), file);
1689
+ return true;
1690
+ } catch {
1691
+ return false;
1692
+ }
1693
+ });
1694
+ if (reading.length === 1) return `${head} ${join(dir, reading[0])} exists there`;
1695
+ const listed = (list: string[]): string =>
1696
+ list.slice(0, LISTED_JSON_NAMES).join(', ') + (list.length > LISTED_JSON_NAMES ? `, … ${list.length} in all` : '');
1697
+ if (reading.length === 0) {
1698
+ return `${head} ${dir} holds ${names.length} .json file(s) and none reads as one: ${listed(names)}`;
1699
+ }
1700
+ return `${head} ${reading.length} .json files in ${dir} read as one, so name it: ${listed(reading)}`;
1701
+ }
1702
+
1703
+ function readJson<T>(path: string, role: JsonInputRole): T {
1653
1704
  let text: string;
1705
+ if (existsSync(path) && statSync(path).isDirectory()) {
1706
+ throw new CompileError(directoryInputRefusal(path, role), 'CE_COMPILE_JSON_READ_FAILED');
1707
+ }
1654
1708
  try {
1655
1709
  text = readFileSync(path, 'utf8');
1656
1710
  } catch (err) {
@@ -2309,11 +2363,11 @@ function compileInto(opts: CompileOptions, droppedStates: DroppedState[]): Compi
2309
2363
  const manifestPath = opts.manifestPath === undefined ? null : resolve(opts.manifestPath);
2310
2364
  const manifestDir = manifestPath === null ? null : dirname(manifestPath);
2311
2365
 
2312
- const rig = parseRigSpec(readJson<unknown>(rigPath), rigPath);
2366
+ const rig = parseRigSpec(readJson<unknown>(rigPath, RIG_INPUT), rigPath);
2313
2367
  // Parsed, not cast, since issue #307 — `src/motion.ts` states what it proves
2314
2368
  // and which refusals it deliberately leaves in this file.
2315
- const motion = parseMotionSpec(readJson<unknown>(motionPath), motionPath);
2316
- const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath);
2369
+ const motion = parseMotionSpec(readJson<unknown>(motionPath, MOTION_INPUT), motionPath);
2370
+ const manifest = manifestPath === null ? null : readJson<FaceManifest>(manifestPath, MANIFEST_INPUT);
2317
2371
  // The manifest is the record of what was measured, and its numbers become
2318
2372
  // bone positions and the stage: a `crop.h` of 1e309 built as `"height": null`
2319
2373
  // before issue #881. The rig spec's walk is `parseRigSpec`'s; this is the
package/src/render.ts CHANGED
@@ -549,9 +549,25 @@ function spinePosed(skeleton: Skeleton): Posed {
549
549
  pieces: (draw) => drawPieces(skeleton, draw),
550
550
  bones: () => boneSnapshots(skeleton),
551
551
  attachments: () => attachmentsOf(skeleton),
552
+ physics: () => steppedPhysicsOf(skeleton),
552
553
  };
553
554
  }
554
555
 
556
+ /**
557
+ * `Posed.physics` on spine-core (issue #1467): every physics constraint whose
558
+ * `lastTime` reads the skeleton's `time` after a step that moved it — which
559
+ * `PhysicsConstraint.update` sets under `Physics.update` and `Physics.reset`,
560
+ * and which an inactive constraint, a mix of 0 or `Physics.none` leaves where
561
+ * it was. The setup pose and frame 0 sit at time 0 and step none.
562
+ */
563
+ function steppedPhysicsOf(skeleton: Skeleton): string[] {
564
+ if (!(skeleton.time > 0)) return [];
565
+ return skeleton.physics
566
+ .filter((c) => c.lastTime === skeleton.time)
567
+ .map((c) => c.data.name)
568
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
569
+ }
570
+
555
571
  /**
556
572
  * A fresh skeleton under `skin`, stepped into its setup pose — the one recipe
557
573
  * `sampleSetupPose` and the geometry export's `rest` table both pose from, so
@@ -102,6 +102,7 @@ import { clipThrough, type ClipShape, type ShapeClipper } from './core/clipping.
102
102
  import { activeBones, CoreInputError, readModel, sourceOfDoc, underNoSkin, underSkin, type CompiledDocument, type CoreSlotRow } from './core/index.ts';
103
103
  import { PHYSICS_CLOCK_LEAD, physicsClockStep, poseRawAnimationEach, poseRawSetup, type RawDrawn, type RawPose, type StepEnvironment } from './core/raw.ts';
104
104
  import type { TimelinePlant } from './core/animation.ts';
105
+ import { stepPhysics, type PhysicsStepper } from './core/constraints_physics.ts';
105
106
  import { CORE_DEFAULT_SKIN, lookupSkins } from './core/skins.ts';
106
107
  import { documentPageLookup, drawnRegions, meshPageUvs, readUvSequences, regionPageUvs, type DrawnRegion, type UvRegion, type UvSource } from './core/uvs.ts';
107
108
  import { regionCorners, worldVertices } from './core/vertices.ts';
@@ -378,10 +379,14 @@ export const CORE_RAW_WALK: CoreRawWalk = (doc, animation, steps, plant, visit,
378
379
  */
379
380
  export { PHYSICS_CLOCK_LEAD, physicsClockStep };
380
381
 
381
- /** What a suite passes `corePoser` in place of the poser's own parts — `PieceArrays` (`RC43`) and the raw walk (`RC44`). */
382
+ /**
383
+ * What a suite passes `corePoser` in place of the poser's own parts — `PieceArrays` (`RC43`), the raw walk (`RC44`), and
384
+ * the physics step the walk runs (issue #1467: a step that does nothing has to read 0 on the render's `physics:` line).
385
+ */
382
386
  export interface CorePoserPlants {
383
387
  arrays?: () => PieceArrays;
384
388
  walk?: CoreRawWalk;
389
+ physicsStep?: PhysicsStepper;
385
390
  }
386
391
 
387
392
  /**
@@ -457,7 +462,7 @@ function clipCover(pose: RawPose, active: ReadonlySet<string>): Map<string, Clip
457
462
  }
458
463
 
459
464
  /** One posed moment of the core, read through the seam's `Posed`. */
460
- function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper): Posed {
465
+ function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper, stepped: readonly string[]): Posed {
461
466
  const slotRows = new Map<string, CoreSlotRow>(pose.slots.map((row) => [row[0], row]));
462
467
  let regions: Map<string, DrawnRegion> | null = null;
463
468
  const regionsOf = (): Map<string, DrawnRegion> => {
@@ -501,6 +506,7 @@ function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper): Pose
501
506
  },
502
507
  attachments: (): AttachmentPose[] =>
503
508
  pose.drawn.map((d) => ({ slot: d.slot, attachment: d.attachment, vertices: [...d.vertices], color: tintOf(d) })),
509
+ physics: (): string[] => [...stepped],
504
510
  };
505
511
  }
506
512
 
@@ -752,7 +758,7 @@ export function corePoser(
752
758
  subset: (opts, skin) => subsetOver(roster, opts, skin),
753
759
  setup: (skin) => {
754
760
  const input = inputOf(skin);
755
- return corePosed(input, poseRawSetup(input.doc, { through }), through);
761
+ return corePosed(input, poseRawSetup(input.doc, { through }), through, []);
756
762
  },
757
763
  animation: (name, skin, fps, count, visit, environment) => {
758
764
  const input = inputOf(skin);
@@ -762,10 +768,22 @@ export function corePoser(
762
768
  // walk that refuses a later pose still refuses the call, and a draw that throws is re-thrown once the walk has
763
769
  // finished without refusing — the first such throw, by frame — and no frame after it is drawn.
764
770
  const drawFailed: unknown[] = [];
765
- walk(input.doc, name, new Array<number>(count).fill(step), { through }, (pose, i) => {
771
+ // `Posed.physics` on the core (issue #1467): the constraints whose state's clock reads the skeleton's after a step
772
+ // that moved it — spine-core's reading (`steppedPhysicsOf` in `src/render.ts`), taken off the same state the step
773
+ // writes, as each constraint is stepped, and handed to the pose the step made.
774
+ const steppedNow = new Set<string>();
775
+ const inner = plants.physicsStep ?? stepPhysics;
776
+ const physicsStep: PhysicsStepper = (r, w, length, ctx) => {
777
+ const moved = inner(r, w, length, ctx);
778
+ if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
779
+ return moved;
780
+ };
781
+ walk(input.doc, name, new Array<number>(count).fill(step), { through, physicsStep }, (pose, i) => {
782
+ const stepped = [...steppedNow].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
783
+ steppedNow.clear();
766
784
  if (drawFailed.length > 0) return;
767
785
  try {
768
- visit(i, corePosed(input, pose, through));
786
+ visit(i, corePosed(input, pose, through, stepped));
769
787
  } catch (thrown) {
770
788
  drawFailed.push(thrown);
771
789
  }
@@ -190,6 +190,20 @@ export interface FrameSet {
190
190
  * file could only ever be the stale one.
191
191
  */
192
192
  duration: number;
193
+ /**
194
+ * Whether the physics ran while these frames were posed (issue #1467): how
195
+ * many physics constraints the poser stepped on at least one frame of this
196
+ * set, and the rate it stepped them at — the set's own `fps`, one skeleton
197
+ * update a frame. `render` prints the same two figures on its `physics:` line.
198
+ *
199
+ * ⭐ Absent on a rig that declares no physics constraint, for `skin`'s
200
+ * reason in `FramesSidecar`: there is nothing for the field to count, and
201
+ * every frame set written before it existed says the same. Present, a 0 is a
202
+ * claim — the rig declares physics and no constraint took a step on any
203
+ * frame — and that is what tells a reading taken with physics from one
204
+ * taken without.
205
+ */
206
+ physics?: { stepped: number; fps: number };
193
207
  }
194
208
 
195
209
  export interface FramesSidecar {
@@ -445,6 +459,8 @@ export interface PoseOptions {
445
459
  * so `framingViewport` keeps it and the box holds what the frames draw.
446
460
  */
447
461
  environment?: readonly EnvironmentStep[];
462
+ /** Also record the physics constraints the step that posed each frame stepped — see `Frame.physics` (issue #1467). */
463
+ physics?: boolean;
448
464
  }
449
465
 
450
466
  /**
@@ -586,6 +602,12 @@ export interface Frame {
586
602
  * second derivation of it.
587
603
  */
588
604
  attachments?: AttachmentPose[];
605
+ /**
606
+ * The physics constraints the step that posed this frame stepped, by name in
607
+ * code-unit order — present only when `PoseOptions.physics` asked for it
608
+ * (issue #1467). See `Posed.physics` for what counts as a step.
609
+ */
610
+ physics?: string[];
589
611
  }
590
612
 
591
613
  /**
@@ -1055,6 +1077,16 @@ export interface Posed {
1055
1077
  bones(): BoneSnapshot[];
1056
1078
  /** Every slot's region or mesh attachment, whole, in the posed draw order. */
1057
1079
  attachments(): AttachmentPose[];
1080
+ /**
1081
+ * The physics constraints the step that posed this moment stepped, by name in
1082
+ * code-unit order (issue #1467). A constraint is stepped when, after a step
1083
+ * that moved the skeleton's clock, its own clock reads the skeleton's: the
1084
+ * physics update sets it there, and every way of not running — inactive, a
1085
+ * mix of 0, the step skipped — leaves it behind. Read off the posed state on
1086
+ * both posers, never off which call was made, so a step removed reads 0. The
1087
+ * setup pose and a reset pose, where the clock has not moved, step none.
1088
+ */
1089
+ physics(): string[];
1058
1090
  }
1059
1091
 
1060
1092
  /**
@@ -1126,6 +1158,7 @@ function frameOf(index: number, time: number, posed: Posed, draw: DrawOptions, o
1126
1158
  pieces: posed.pieces(draw),
1127
1159
  ...(opts?.bones ? { bones: posed.bones() } : {}),
1128
1160
  ...(opts?.geometry ? { attachments: posed.attachments() } : {}),
1161
+ ...(opts?.physics ? { physics: posed.physics() } : {}),
1129
1162
  };
1130
1163
  }
1131
1164
 
package/src/stop.ts CHANGED
@@ -126,6 +126,37 @@ interface FirstLink {
126
126
  point: readonly [number, number];
127
127
  }
128
128
 
129
+ /**
130
+ * Every number a stop's refusal prints, in one place (issue #1463). How it is
131
+ * printed depends on what the number is to the author:
132
+ *
133
+ * - `stated` — a value the author typed (`inward`, `outward`, `at`), echoed
134
+ * exactly as JavaScript reads it back, so it can be found in the rig spec.
135
+ * It is never rounded: two stated ends 2.60251 and 2.60249 would both print
136
+ * 2.603, and the refusal would read "2.603 is greater than 2.603";
137
+ * - `reading` — a figure the expansion derived (a hang, a world position, a
138
+ * rest angle), at the three decimals the compiler's other degree refusals
139
+ * print, rounded to nearest;
140
+ * - `past` — a figure refused for lying past ±180°, rounded **away from
141
+ * zero**, so it never prints on the bound: 180.0000001 prints 180.001,
142
+ * not 180.000, which would read as inside the range;
143
+ * - `remedy` — a figure the refusal offers the author to state ("at most",
144
+ * "at least", "within"), rounded **toward zero**, so stating the printed
145
+ * value is accepted rather than refused again by the digits dropped.
146
+ *
147
+ * The rule is that rounding never moves a figure across the line the
148
+ * refusal is about: a `past` figure stays past it, a `remedy` stays inside.
149
+ */
150
+ function stopFigure(v: number, how: 'stated' | 'reading' | 'past' | 'remedy'): string {
151
+ if (how === 'stated') return String(v);
152
+ const near = Number(v.toFixed(3));
153
+ let r = near;
154
+ if (how === 'past' && Math.abs(near) < Math.abs(v)) r = near + Math.sign(v) * 0.001;
155
+ if (how === 'remedy' && Math.abs(near) > Math.abs(v)) r = near - Math.sign(v) * 0.001;
156
+ const text = r.toFixed(3);
157
+ return text === '-0.000' ? '0.000' : text;
158
+ }
159
+
129
160
  /** An angle in (-540, 540) brought into (-180, 180] by at most one whole turn. */
130
161
  function halfTurn(deg: number): number {
131
162
  if (deg > 180) return deg - 360;
@@ -216,7 +247,7 @@ export function expandStops(spec: RigSpec, where: string, plant: StopPlant = {})
216
247
  }
217
248
  if (out.inward > out.outward) {
218
249
  throw new CompileError(
219
- `${who}: "${field}inward" ${out.inward} is greater than "${field}outward" ${out.outward} — the range is [inward, outward] in degrees counted positive away from "${against}", and this one holds no angle`, 'CE_STOP_RANGE_REVERSED',
250
+ `${who}: "${field}inward" ${stopFigure(out.inward, 'stated')} is greater than "${field}outward" ${stopFigure(out.outward, 'stated')} — the range is [inward, outward] in degrees counted positive away from "${against}", and this one holds no angle`, 'CE_STOP_RANGE_REVERSED',
220
251
  );
221
252
  }
222
253
  return out;
@@ -350,15 +381,16 @@ export function expandStops(spec: RigSpec, where: string, plant: StopPlant = {})
350
381
  const e = stop[end];
351
382
  const off = Math.abs(rest);
352
383
  const sum = off + e;
384
+ const position = (p: readonly number[], how: 'stated' | 'reading'): string => p.map((v) => stopFigure(v, how)).join(', ');
353
385
  const geometry =
354
- `. "${stop.links[0]}" hangs at ${at.hang}° from its root at [${at.root.join(', ')}], and "${stop.names.point}" ("at" [${stop.at.join(', ')}] on "${stop.against}") ` +
355
- `sits at [${at.point.join(', ')}] — Spine world, y up, at the setup pose — so the aim from that root to the point lies ${off}° off the hang (rest ${rest}°): ` +
356
- `|rest| ${off}° + "${end}" ${e}° is ${sum}°`;
357
- if (sum < -BOUND) return `${geometry}, below -${BOUND}°. State "${end}" at least ${-BOUND - off}°`;
386
+ `. "${stop.links[0]}" hangs at ${stopFigure(at.hang, 'reading')}° from its root at [${position(at.root, 'reading')}], and "${stop.names.point}" ("at" [${position(stop.at, 'stated')}] on "${stop.against}") ` +
387
+ `sits at [${position(at.point, 'reading')}] — Spine world, y up, at the setup pose — so the aim from that root to the point lies ${stopFigure(off, 'reading')}° off the hang (rest ${stopFigure(rest, 'reading')}°): ` +
388
+ `|rest| ${stopFigure(off, 'reading')}° + "${end}" ${stopFigure(e, 'stated')}° is ${stopFigure(sum, 'past')}°`;
389
+ if (sum < -BOUND) return `${geometry}, below -${BOUND}°. State "${end}" at least ${stopFigure(-BOUND - off, 'remedy')}°`;
358
390
  if (e >= BOUND) return `${geometry}, above ${BOUND}°, and no point lowers it: |rest| is above 0° for every point a stop accepts. State "${end}" below ${BOUND}°`;
359
391
  return (
360
- `${geometry}, above ${BOUND}°. Move "${stop.names.point}" to within ${BOUND - e}° (${BOUND}° − "${end}") of the direction "${stop.links[0]}" hangs in from its root, ${at.hang}°, ` +
361
- `or state "${end}" at most ${BOUND - off}°`
392
+ `${geometry}, above ${BOUND}°. Move "${stop.names.point}" to within ${stopFigure(BOUND - e, 'remedy')}° (${BOUND}° − "${end}") of the direction "${stop.links[0]}" hangs in from its root, ${stopFigure(at.hang, 'reading')}°, ` +
393
+ `or state "${end}" at most ${stopFigure(BOUND - off, 'remedy')}°`
362
394
  );
363
395
  };
364
396
  const bounds = (stop: ReadStop, rest: number, sign: number, range: { inward: number; outward: number }, field: string, what: string, first?: FirstLink): [number, number] => {
@@ -368,7 +400,7 @@ export function expandStops(spec: RigSpec, where: string, plant: StopPlant = {})
368
400
  if (Math.abs(value) > BOUND) {
369
401
  const why = first === undefined ? '' : aimed(stop, rest, end, first);
370
402
  throw new CompileError(
371
- `${stop.who}: "${field}${end}" ${range[end]} puts ${what}'s bound at ${value}°, past ±${BOUND}° — the clamp reads a local rotation in (-180, 180], so a bound past it is never reached and the range it states is not the one that holds${why}`, 'CE_STOP_LOCAL_BOUND_RANGE',
403
+ `${stop.who}: "${field}${end}" ${stopFigure(range[end], 'stated')} puts ${what}'s bound at ${stopFigure(value, 'past')}°, past ±${BOUND}° — the clamp reads a local rotation in (-180, 180], so a bound past it is never reached and the range it states is not the one that holds${why}`, 'CE_STOP_LOCAL_BOUND_RANGE',
372
404
  );
373
405
  }
374
406
  }
@@ -422,7 +454,7 @@ export function expandStops(spec: RigSpec, where: string, plant: StopPlant = {})
422
454
  const dx = target[0] - linkFrame.worldX;
423
455
  const dy = target[1] - linkFrame.worldY;
424
456
  if (dx === 0 && dy === 0) {
425
- throw new CompileError(`${who}: "at" [${stop.at.join(', ')}] on "${stop.against}" sits on the root of "${links[0]}" at the setup pose — the frame aims from the root at the point, and there is no direction to a point it stands on`, 'CE_STOP_POINT_AT_ROOT');
457
+ throw new CompileError(`${who}: "at" [${stop.at.map((v) => stopFigure(v, 'stated')).join(', ')}] on "${stop.against}" sits on the root of "${links[0]}" at the setup pose — the frame aims from the root at the point, and there is no direction to a point it stands on`, 'CE_STOP_POINT_AT_ROOT');
426
458
  }
427
459
  const aim = directionDegrees(dx, dy);
428
460
  // Each angle is brought into (-180, 180] by one whole turn at most, so a
@@ -431,7 +463,7 @@ export function expandStops(spec: RigSpec, where: string, plant: StopPlant = {})
431
463
  const rest = halfTurn(halfTurn(linkFrame.worldRotation) - aim);
432
464
  if (rest === 0) {
433
465
  throw new CompileError(
434
- `${who}: "at" [${stop.at.join(', ')}] on "${stop.against}" lies on "${links[0]}"'s own line at the setup pose — inward is the turn from the link toward the point, and a point on the link's line has no side`, 'CE_STOP_POINT_ON_LINK_LINE',
466
+ `${who}: "at" [${stop.at.map((v) => stopFigure(v, 'stated')).join(', ')}] on "${stop.against}" lies on "${links[0]}"'s own line at the setup pose — inward is the turn from the link toward the point, and a point on the link's line has no side`, 'CE_STOP_POINT_ON_LINK_LINE',
435
467
  );
436
468
  }
437
469
  // Inward turns the link toward the aim line: counter-clockwise when the link