@rig-c/core 2.80.0 → 2.81.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +2 -2
- package/README.md +2 -2
- package/docs/AUTHORING.md +93 -3
- package/docs/FACE.md +3 -3
- package/package.json +1 -1
- package/skills/rigc-face/SKILL.md +2 -2
- package/skills/rigc-ingest/SKILL.md +2 -2
- package/skills/rigc-motion/SKILL.md +2 -2
- package/skills/rigc-rigging/SKILL.md +2 -2
- package/src/cli/core_commands.ts +44 -4
- package/src/cli/shared.ts +4 -3
- package/src/core/animation.ts +2 -2
- package/src/core/index.ts +3 -1
- package/src/extremes.ts +198 -0
- package/src/render.ts +20 -4
- package/src/render_channels.ts +36 -0
- package/src/render_core.ts +32 -8
- package/src/render_shared.ts +27 -7
package/NOTICE.md
CHANGED
|
@@ -8,8 +8,8 @@ Copyright (c) 2013-2025 Esoteric Software LLC, licensed under the
|
|
|
8
8
|
[Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license),
|
|
9
9
|
as a **development dependency**: a clone and CI install it, and every Spine file
|
|
10
10
|
`rigc build` writes there is read back through it before it is written. **The
|
|
11
|
-
published package does not carry it** — an install of
|
|
12
|
-
`spine-rigc`, the same files under the
|
|
11
|
+
published package does not carry it** — an install of `@rig-c/core` (or of `rig-c` or
|
|
12
|
+
`spine-rigc`, the same product files under the convenience or legacy name) has no
|
|
13
13
|
Spine runtime in it unless one is installed beside it, and then the same `rigc`
|
|
14
14
|
uses it.
|
|
15
15
|
|
package/README.md
CHANGED
|
@@ -223,7 +223,7 @@ candidate chooser (`loadCandidate`, `throughPoser`), setup and animation sampler
|
|
|
223
223
|
`rasterisePiece`, `renderFrame`), with their argument/result types and named
|
|
224
224
|
failures. It loads without the optional Spine runtime; a Spine input requires
|
|
225
225
|
explicitly loading `rig-c/render`, whose eager runtime dependency is unchanged.
|
|
226
|
-
The promised scope and
|
|
226
|
+
The promised scope and the first release (2.69.0) are in
|
|
227
227
|
[RELEASING.md, The import surface](https://github.com/rig-c/core/blob/main/RELEASING.md#the-import-surface);
|
|
228
228
|
[AUTHORING §0.5](docs/AUTHORING.md#05-reading-a-build-from-code) shows the call chain.
|
|
229
229
|
|
|
@@ -647,7 +647,7 @@ commands take it and what its default is.
|
|
|
647
647
|
| `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 |
|
|
648
648
|
| `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)) |
|
|
649
649
|
| `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 |
|
|
650
|
-
| `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 ([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. `--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 |
|
|
650
|
+
| `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 ([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. 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 |
|
|
651
651
|
| `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` |
|
|
652
652
|
| `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
|
|
653
653
|
| `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
|
package/docs/AUTHORING.md
CHANGED
|
@@ -146,6 +146,9 @@ bun cli.ts check \
|
|
|
146
146
|
# read the table → fix the spec → build again → check again
|
|
147
147
|
# ↳ read its per-frame column before its MAE — §9.2
|
|
148
148
|
|
|
149
|
+
# Before a person looks: inspect each motion's min, max and diff (§8.11).
|
|
150
|
+
bun cli.ts render --candidate path/to/spine --extremes --out frames/
|
|
151
|
+
|
|
149
152
|
# …and if nobody gave you frames, LOOK at it instead — neither needs a reference:
|
|
150
153
|
bun cli.ts render --candidate path/to/spine # PNG frames + a contact sheet grid
|
|
151
154
|
bun cli.ts preview --candidate path/to/spine # one .html that plays it in Spine's own player
|
|
@@ -277,8 +280,13 @@ What the flags mean:
|
|
|
277
280
|
| `--report` | `build` and `repack`: also write the gate's report as a JSON document (`build-report/1`) to this file — every gate's `PASS`, `SKIP` and `FAIL` rows, its summary figures and stats line, which supplier judged it, and every `pack:` line's figures. Written when the command writes `--out` and when a gate is red, before the exit; a file already at the path is removed first, so a compile error or a refused repack leaves none. Never inside `--out`, refused by name. The printed lines are byte-identical with it and without it — **§5.3** |
|
|
278
281
|
| `--candidate` | `check`, `bench`, `render`, `inspect`, `preview`, `chainfit` and `vote` only: a **compiled** artifact — the directory `build --out` wrote, or a `skeleton.json` path. A directory holding no `skeleton.json`, or a path with nothing at it, is refused `nothing at <path>`, exit 2, nothing written, by every command that takes a build — `validate <dir>` and a directory given to `diff` included (`RC27`). `--atlas <path>` names the atlas when it does not sit beside the skeleton. On `render` and `check` a rigc build whose `skeleton.model.json` is `rigc-compiled/3` or `/2` needs no atlas at all: with no `--atlas` and no atlas beside it, it is drawn from the document's `pages`, the same frames, geometry and figures as with it, and the `atlas` line says `— not there`. An atlas that is there is still read, and held to `pages`: one that differs (edited after the build) is drawn through spine-core, and the `poser` line names the first difference. A build drawn through an atlas that is not there — a `rigc-compiled/1` document, or a build the core refuses — is refused `nothing at <the atlas>: … is drawn through its atlas (<why>)`, exit 2, nothing written; a Spine export without its atlas is refused `nothing at <the atlas>` as it always was. A directory whose files are not one build — a `skeleton.json` or a `skeleton.atlas` from another build beside this one's `skeleton.model.json` — is drawn through spine-core where the runtime can load the pair, the `poser` line naming the two digests or the atlas's first difference; where it cannot, it is refused `<skeleton> does not load against <the atlas>: spine-core draws this pair (<the poser line's why>) and could not resolve it — "<the runtime's own words>". <dir> is not one build: …`, exit 2, nothing written (`RC18`) — another build's skeleton beside this build's atlas typically ends `"Region not found in atlas: <region> (attachment: <name>)"`. Rebuild, or put the build's own three files back beside each other. A Spine export beside an atlas it was not exported with is refused in the same words, ending `The skeleton and the atlas are not one pair`. An atlas copied from another build's directory usually stops earlier, at its pages — their paths are relative to the directory it was written in — and is refused naming the first one: `nothing at <the page>: <the atlas> names it as a page, and spine-core draws this pair through that atlas (<why>). … <dir> is not one build: …`, exit 2, nothing written (`RC18`); an export whose atlas names a page that is not there is refused the same way, ending `and the page has to be there`. A build moved or copied whole away from the directory it was built in stops at the same place, on either poser, and is refused naming the page and the file that names it: `nothing at <the page>: <the skeleton.model.json> names it as a page, and the core draws this build from it. A page path is relative to the build's directory, so a build moved or copied away from the directory it was built in names pages that are not here — build it again where it is, or build it with --copy-images, which writes its pages beside it` (under `--poser spine`, `<the atlas> names it as a page, and spine-core draws this pair through that atlas (--poser spine)` with the same ending), exit 2, nothing written (`RC21`); only where the core refused the files does a page refusal say the directory is not one build. `bench --bones` and `rigc bonedist` — on its `--candidate` and on its `--reference` — refuse a pair the runtime cannot load in the words above with `spine-core poses this pair (bonedist's candidate: …)` or `(bonedist's reference: …)`, exit 2, nothing written (`RC20`); they read no page image, so a build whose pages are elsewhere is measured as it is in place. A `skeleton.json` that is not JSON is refused `rigc: cannot read <the file>: <the parser's words> (line N, column N)`, exit 2, by `render`, `check`, `bench` and `bonedist` as by every other command that reads one (`RC22`). With the atlas gone, `check` on a `rigc-compiled/3` build reads its pages' `scale:` lines off the document, so its texture note and `check.json`'s `textureFrom.candidateScales` are what they are with the atlas; on a `/2` build its texture note says whether the atlas declares a `scale:` line is not read (that document does not state one), and `candidateScales` is `null` (`RC13`). **`vote` and `preview` take it more than once**: `vote` 2–4 times, one per pane, labelled A, B, C, D in the order given; `preview` any number of times, one pane per candidate in the order given, each headed by its path and its gate line, and the same skeleton twice (a directory and its `skeleton.json` are one) is refused by name. `--atlas` goes with one candidate only. Everywhere else a repeat is a typo and is refused |
|
|
279
282
|
| `--animation` | `render`, `preview` and `vote` only: which animation to show. The default is **every** one for `render`, the **first** for `preview` (each candidate's own first, with several), and for `vote` the first of candidate A. A name the skeleton does not have is refused, with the ones it does have listed — and for `vote` and a several-candidate `preview`, so is a name that only *some* candidates have, naming the one that lacks it |
|
|
283
|
+
| `--slot` | On `render`: draw only these comma-separated slot names, in draw order, on the **same grid** as the whole rig (the viewport still fits every slot). Default: all slots. Unknown names or art available only in another skin are refused by name. `frames.json` records the subset, and `check` refuses it as a reference. `split` uses this flag to name the mesh to separate — **§3.4.11** |
|
|
284
|
+
| `--hide` | On `render`: draw every slot except these comma-separated names, on the same grid and with the same subset record and refusals as `--slot`. Default: hide none. Refused with `--slot` |
|
|
285
|
+
| `--fps` | On `render`: frames per second to sample (default `12`, finite and at least `1`); a non-default rate writes `<anim>@<fps>fps/`, while `12` writes `<anim>/`. `bonedist` samples both skeletons at this rate (default `12`); `inspect` uses it for the `--at` grid (default `12`, **§13**); `check` uses it only for a frame set with no `frames.json` sidecar (**§9**) |
|
|
286
|
+
| `--max` | `render` and `inspect`: the longest side of the frame in pixels (default `256`, finite and at least `16`). On `render`, refused with `--viewport-from`, which already fixes the pixel size — **§8.6**, **§13** |
|
|
280
287
|
| `--show`, `--dim`, `--at`, `--zoom` | `inspect` only — what it draws, whether the art is faded, which times of `--animation` it poses and the bone, slot or folds it frames to; §13 says each, with the refusals |
|
|
281
288
|
| `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
|
|
289
|
+
| `--extremes` | `render` only: add `extremes.json` and an `EXTREMES` line per set; a moving set with comparable geometry and readable images also gets a native six-panel `extremes.png`: setup, geometry-pair min/max and three premultiplied RGBA diffs with a legend. Per-channel extrema are independent of that geometry pair; valid channels survive geometry/image SKIPs, and channel refusals are named separately. Still or uncomparable geometry is a named SKIP, with no invented pair or PNG; observation failures do not change a successful render's exit status. Supports `--slot`/`--hide` on both posers, on the whole rig's viewport. Without the flag, no extremes observations are added — **§8.11** |
|
|
282
290
|
| `--folds` | `render` only: also read which mesh triangles **fold** — turn over against the setup pose — and write a `folds.json` into each frame directory: per mesh, the frames with a folded triangle, the worst frame's count and which triangles, by index, with their world box and setup-pose area. One printed line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. A reading, not a gate. Every other file is byte for byte what the render writes without it, `geometry.json` included. Refused with `--slot`/`--hide`, for `--geometry`'s reason — **§8.3** |
|
|
283
291
|
| `--pass-through` | `render` only: `<cloth>:<leg>[,<cloth>:<leg>…]` — also read where each cloth slot **passes through** its leg slot, and write a `pass-through.json` into each frame directory: the frames where the cloth draws over the leg and its drawn side lies inside the leg's drawn outline by 2 steps or more, each with the depth and the world box. One printed line per pair, and one per frame that passes through. The first name must draw over the second for a frame to be read — the declared order, never both. A reading, not a gate. Every other file is byte for byte what the render writes without it. An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name, and so is `--slot`/`--hide` beside it — **§8.5** |
|
|
284
292
|
| `--seam` | `render` only: `<slot>:<slot>[,<slot>:<slot>…]` — also read, for each pair, where the two drawings **stop meeting**, and write a `seam.json` into each frame directory: per pair, the frames with a **gap** (background inside the joint, where the two covered together at the setup pose) and the frames with a **ghost** (the overlap band of one showing where, from its partner's point of view, nothing was drawn), each with its cell count, area and world box, and as runs of frames with their times. One printed line per pair; a pair whose drawings share no cell at the setup pose is a **SKIP**. A reading, not a gate: the exit status never moves. Every other file is byte for byte what the render writes without it. An unknown slot, a slot with itself, a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name, and so is `--slot`/`--hide` beside it — **§8.8** |
|
|
@@ -1643,9 +1651,9 @@ written`, followed by `rigc repack: nothing was written to <out>; the work direc
|
|
|
1643
1651
|
### 0.5 Reading a build from code
|
|
1644
1652
|
|
|
1645
1653
|
The named `rig-c/render-shared` entry promises the official renderer reads for
|
|
1646
|
-
checked consumers (issue #1561)
|
|
1647
|
-
|
|
1648
|
-
|
|
1654
|
+
checked consumers (issue #1561), available since 2.69.0; pin
|
|
1655
|
+
`>=2.69.0 <3.0.0`. It requires Bun and the candidate's real files, but imports
|
|
1656
|
+
no optional Spine runtime. The existing
|
|
1649
1657
|
`rig-c/render` entry still eagerly imports that runtime.
|
|
1650
1658
|
|
|
1651
1659
|
```ts
|
|
@@ -9464,6 +9472,88 @@ bounds plus its `2`-px border on each side exceed `2048` (`w + 4 > 2048` or
|
|
|
9464
9472
|
|
|
9465
9473
|
---
|
|
9466
9474
|
|
|
9475
|
+
### 8.11 Min, max and diff before a person looks — `render --extremes`
|
|
9476
|
+
|
|
9477
|
+
```sh
|
|
9478
|
+
bun cli.ts render --candidate path/to/build --extremes [--animation turn] [--fps 12] [--max 256]
|
|
9479
|
+
```
|
|
9480
|
+
|
|
9481
|
+
Look at **min · max · diff before a person does**. A baked line carried by a
|
|
9482
|
+
moving part can be obvious in the difference image while a seam metric misses
|
|
9483
|
+
it. This flag makes the picture; it does not classify tears or ghosts and does
|
|
9484
|
+
not change the seam metric.
|
|
9485
|
+
|
|
9486
|
+
Each set gets `extremes.json` and one `EXTREMES` line. A moving set also gets
|
|
9487
|
+
`extremes.png`, with six panels from left to right:
|
|
9488
|
+
**setup | min | max | abs(max-min) | abs(min-setup) | abs(max-setup)**.
|
|
9489
|
+
Every panel has the render's native viewport width, height and scale; the legend
|
|
9490
|
+
is appended below, wrapping its text without shrinking any picture. The three
|
|
9491
|
+
pose panels are transparent renders. Each diff pixel is opaque greyscale on
|
|
9492
|
+
black, with luminance
|
|
9493
|
+
`round(max(abs(Ra*Aa/255-Rb*Ab/255), abs(Ga*Aa/255-Gb*Ab/255), abs(Ba*Aa/255-Bb*Ab/255), abs(Aa-Ab)))`.
|
|
9494
|
+
Alpha participates; RGB behind alpha zero contributes zero. There is no contrast
|
|
9495
|
+
amplification or additional scaling.
|
|
9496
|
+
|
|
9497
|
+
The JSON keeps two different observations separate:
|
|
9498
|
+
|
|
9499
|
+
- **`channels`**: every sampled local bone rotation, componentwise translation,
|
|
9500
|
+
signed scale and shear, deform offset coordinate (per influence for weighted
|
|
9501
|
+
meshes), and each applied slider's mapped time, with its minimum and maximum,
|
|
9502
|
+
exact frame timestamp and selection reason. Held bone components are included
|
|
9503
|
+
explicitly. A slider applies its own mapping; its time is read from the pose,
|
|
9504
|
+
rather than guessed from elapsed time. A mix-zero slider or one whose driver
|
|
9505
|
+
bone is inactive did not apply a mapping and contributes no stale time.
|
|
9506
|
+
- **`geometryPair`**: exhaustively compare frame pairs on the set's own grid.
|
|
9507
|
+
For a pair, measure the Euclidean world distance of each corresponding original
|
|
9508
|
+
attachment vertex with the shared deterministic `sqrt(dx*dx+dy*dy)` length
|
|
9509
|
+
helper, and take the greatest. Select the pair with the greatest
|
|
9510
|
+
such distance. Record that maximum in world units and its slot, attachment and
|
|
9511
|
+
vertex witness. `min` and `max` in the picture mean the earlier and later
|
|
9512
|
+
frame of **this pair**; they are not a pose combining every channel's minima
|
|
9513
|
+
or maxima. The channel table can choose entirely different frames.
|
|
9514
|
+
|
|
9515
|
+
`channelStatus` (`READ`, `PARTIAL` or `SKIP`) and `channelRefusals` describe
|
|
9516
|
+
channel validity separately from the picture's `status` and `reason`. A geometry
|
|
9517
|
+
correspondence failure or failed optional image read **preserves every valid
|
|
9518
|
+
independent channel extremum**; an image failure also retains a valid geometry
|
|
9519
|
+
pair if one was measured. A non-finite scalar refuses only that channel. If a
|
|
9520
|
+
whole frame's channel reader failed, extrema on the full grid are unknown and
|
|
9521
|
+
the channel table is a named SKIP while geometry can still be read.
|
|
9522
|
+
|
|
9523
|
+
Deform samples are offsets from their original source, read only where that
|
|
9524
|
+
posed attachment supplies the coordinate. A deform path names slot, attachment
|
|
9525
|
+
display name and coordinate. If two different original sources share that path,
|
|
9526
|
+
its extrema cannot be represented unambiguously under the name: refuse that
|
|
9527
|
+
series by name in `channelRefusals`, preserving unrelated bone, slider and other
|
|
9528
|
+
deform series. Missing original deform-source identity is refused the same way;
|
|
9529
|
+
never merge unrelated coordinates because their names happen to match.
|
|
9530
|
+
|
|
9531
|
+
Ties keep the earliest frame for a channel and the earliest ordered frame pair
|
|
9532
|
+
for geometry, then the first attachment identity in UTF-16 code-unit order and
|
|
9533
|
+
vertex index. Times are copied from sampled frames. No continuous-time extremum
|
|
9534
|
+
between grid points is claimed. A single frame or a set with no selected vertex
|
|
9535
|
+
motion has `status: "SKIP"`, a named reason and no invented pair or PNG. Channels
|
|
9536
|
+
still describe the samples, including any local change leaving geometry still.
|
|
9537
|
+
|
|
9538
|
+
Both posers support `--slot` and `--hide`: resolve the flags normally, then filter
|
|
9539
|
+
original attachment geometry by those slot names. The viewport stays the whole
|
|
9540
|
+
rig's. This reads original vertices **before clipping, occlusion and tint alpha**;
|
|
9541
|
+
re-clipped triangles never supply cross-frame indices. An attachment switch,
|
|
9542
|
+
missing attachment or changed vertex count cannot establish correspondence.
|
|
9543
|
+
The flag also checks the actual original source identity: two different
|
|
9544
|
+
attachments can have the same display name and vertex count. That identity is
|
|
9545
|
+
kept outside serialized geometry so existing geometry files are unchanged.
|
|
9546
|
+
If the optional source identity was not captured, correspondence is unavailable
|
|
9547
|
+
and is refused by name too. When correspondence cannot be established, the
|
|
9548
|
+
set writes a named `EXTREMES_OBSERVATION_REFUSED` SKIP instead of comparing
|
|
9549
|
+
unrelated indices. Optional image failures preserve the independently collected
|
|
9550
|
+
channels. Channel failures are reported separately as described above.
|
|
9551
|
+
**Observation failures do not change a successful render's exit status.** Invalid CLI arguments retain their normal refusal semantics.
|
|
9552
|
+
|
|
9553
|
+
Without `--extremes` the existing frames and files are unchanged. The flag adds
|
|
9554
|
+
its observations alongside them. A SKIP removes a stale `extremes.png` for that
|
|
9555
|
+
set so an earlier pair cannot masquerade as the current reading.
|
|
9556
|
+
|
|
9467
9557
|
## 9. Checking against the frames — `rigc check`
|
|
9468
9558
|
|
|
9469
9559
|
```bash
|
package/docs/FACE.md
CHANGED
|
@@ -545,9 +545,9 @@ renderer must never idle-skip a mesh. Keying the pivot one link up satisfies it,
|
|
|
545
545
|
and **the rig that satisfies the assertion is the better rig anyway.**
|
|
546
546
|
|
|
547
547
|
⚠️ **A15 assumes the meshes are mostly static, and that assumption is the rule's
|
|
548
|
-
whole premise.** The
|
|
549
|
-
face whose `idle` breathes through one pivot
|
|
550
|
-
nothing keys the meshes' own bones. The rule reads bone **names**: keying `headroll`
|
|
548
|
+
whole premise.** The `@rig-c/play` renderer (the `rig-play` profile's player) skips
|
|
549
|
+
redrawing a mesh nothing moved, so a face whose `idle` breathes through one pivot
|
|
550
|
+
keeps every mesh under it cheap only if nothing keys the meshes' own bones. The rule reads bone **names**: keying `headroll`
|
|
551
551
|
passes it, and `head`'s mesh still moves, because a child's world transform is
|
|
552
552
|
composed from its parent's — so pivoting is right here for the anatomical reason
|
|
553
553
|
above, not because it stops a redraw. A **painting rig** is the genre where the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.81.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": {
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rigc-face
|
|
3
3
|
description: Author a face on plain Spine data with rigc — a blink, a gaze shift, a breathing portrait and a head turn a few degrees off axis, built from deform timelines and per-part parallax. Use when the request is a talking or living portrait, a standing character, an expression or a head turn, such as "rig this face", "make the portrait blink and look around" or "turn the head". Not for Live2D file conversion, cutting a face illustration into parts, or VTuber-style real-time face tracking.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility: Requires Bun 1.4 or later and the npm package rig-c.
|
|
5
|
+
compatibility: Requires Bun 1.4 or later and the npm package @rig-c/core (rig-c is the convenience name).
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Face — a turn, a gaze and a blink
|
|
@@ -63,7 +63,7 @@ differential audit and the three limits it does not lift.
|
|
|
63
63
|
rather than this closed form, and [INGEST.md](https://github.com/rig-c/core/blob/main/docs/INGEST.md) if the head
|
|
64
64
|
arrived as a compiled skeleton.
|
|
65
65
|
|
|
66
|
-
Every guide linked here is in the installed package at `node_modules
|
|
66
|
+
Every guide linked here is in the installed package at `node_modules/@rig-c/core/docs/`,
|
|
67
67
|
which is the copy that matches the rigc you run; the links go to the repository's
|
|
68
68
|
`main`. Inside the Claude Code plugin the same files are at `${CLAUDE_PLUGIN_ROOT}/docs/`.
|
|
69
69
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rigc-ingest
|
|
3
3
|
description: Work with a Spine skeleton.json somebody else authored — exported from the Spine editor or another tool — using rigc. Read and validate it, understand a complaint rigc raised about it, decompile it into rigc specs with `rigc ingest`, normalise, re-pivot or rename it, and extend it with an animation it does not have. Use when the input is an existing skeleton.json with its .atlas and page images rather than loose part PNGs. Not for Live2D file conversion or runtime tracking.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility: Requires Bun 1.4 or later and the npm package rig-c.
|
|
5
|
+
compatibility: Requires Bun 1.4 or later and the npm package @rig-c/core (rig-c is the convenience name).
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Ingest — a skeleton you did not author
|
|
@@ -81,7 +81,7 @@ thing to reach for; transcription by hand is what you fall back on for a constru
|
|
|
81
81
|
3. Then [RIGGING.md](https://github.com/rig-c/core/blob/main/docs/RIGGING.md) for why the re-pivot edit has the shape
|
|
82
82
|
it has, and [MOTION.md](https://github.com/rig-c/core/blob/main/docs/MOTION.md) for the animation you are adding.
|
|
83
83
|
|
|
84
|
-
Every guide linked here is in the installed package at `node_modules
|
|
84
|
+
Every guide linked here is in the installed package at `node_modules/@rig-c/core/docs/`,
|
|
85
85
|
which is the copy that matches the rigc you run; the links go to the repository's
|
|
86
86
|
`main`. Inside the Claude Code plugin the same files are at `${CLAUDE_PLUGIN_ROOT}/docs/`.
|
|
87
87
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rigc-motion
|
|
3
3
|
description: Author a Spine animation with rigc from key poses — an idle, a loop, a move from one picture to another — with timing and spacing, ease in and out, anticipation, arcs, overlap, follow-through, squash and stretch, and candidate variants a person can choose between. Use when the request is a movement on an existing or planned rig, in the animator's words too: "animate this rig", "make it breathe", "go from pose A to pose B", "make it feel heavier", "the cape should follow through". Not for Live2D, separating an image into parts, or real-time tracking.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility: Requires Bun 1.4 or later and the npm package rig-c.
|
|
5
|
+
compatibility: Requires Bun 1.4 or later and the npm package @rig-c/core (rig-c is the convenience name).
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Motion — what goes between two poses
|
|
@@ -54,7 +54,7 @@ thing that judges a movement is a person's eye through `rigc vote` — MOTION §
|
|
|
54
54
|
head turn; [INGEST.md](https://github.com/rig-c/core/blob/main/docs/INGEST.md) if the rig arrived as a compiled
|
|
55
55
|
skeleton rather than loose parts.
|
|
56
56
|
|
|
57
|
-
Every guide linked here is in the installed package at `node_modules
|
|
57
|
+
Every guide linked here is in the installed package at `node_modules/@rig-c/core/docs/`,
|
|
58
58
|
which is the copy that matches the rigc you run; the links go to the repository's
|
|
59
59
|
`main`. Inside the Claude Code plugin the same files are at `${CLAUDE_PLUGIN_ROOT}/docs/`.
|
|
60
60
|
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rigc-rigging
|
|
3
3
|
description: Decide a Spine rig's hierarchy with rigc — how many bones, where each pivot sits, what hangs off what, offsets, chains and what a chain can reach, siblings versus chains, constraints as structure — and which of those decisions the reference frames can check. Use when the request is a skeleton from loose part PNGs, such as "rig these parts", "make a Spine skeleton" or "where do the joints go", before any motion is authored. Not for Live2D, cutting an illustration into parts, or VTuber-style tracking.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility: Requires Bun 1.4 or later and the npm package rig-c.
|
|
5
|
+
compatibility: Requires Bun 1.4 or later and the npm package @rig-c/core (rig-c is the convenience name).
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# Rigging — the hierarchy itself
|
|
@@ -52,7 +52,7 @@ are named in RIGGING §11 — neither as a pass bar.
|
|
|
52
52
|
[INGEST.md](https://github.com/rig-c/core/blob/main/docs/INGEST.md) if the skeleton was handed to you already
|
|
53
53
|
compiled.
|
|
54
54
|
|
|
55
|
-
Every guide linked here is in the installed package at `node_modules
|
|
55
|
+
Every guide linked here is in the installed package at `node_modules/@rig-c/core/docs/`,
|
|
56
56
|
which is the copy that matches the rigc you run; the links go to the repository's
|
|
57
57
|
`main`. Inside the Claude Code plugin the same files are at `${CLAUDE_PLUGIN_ROOT}/docs/`.
|
|
58
58
|
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extremesOf, extremesText, extremesPlate, extremesLine, extremesRefusal } from '../extremes.ts';
|
|
1
2
|
/**
|
|
2
3
|
* The bodies of the commands that reach nothing of spine-core — `explain`,
|
|
3
4
|
* `ingest`, `diff`, `check`, `render`, `inspect`, `pose`, `chainfit` and `skills` —
|
|
@@ -44,7 +45,7 @@ import { buildSheetSet, SHEET_MANIFEST_FILE, type SheetSetPack, SheetUsageError,
|
|
|
44
45
|
import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText } from '../seam.ts';
|
|
45
46
|
import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
|
|
46
47
|
import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
|
|
47
|
-
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, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser, type Viewport, viewportFor } from '../render_shared.ts';
|
|
48
|
+
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';
|
|
48
49
|
import { type CompileResult } from '../types.ts';
|
|
49
50
|
import { attachmentRegionJoins } from '../region_joins.ts';
|
|
50
51
|
import { cmdRepack } from './repack.ts';
|
|
@@ -671,6 +672,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
671
672
|
const sheetPageEdges = wantsSheet ? readPageEdges(flags) : undefined;
|
|
672
673
|
const outRoot = resolve(flags.out ?? 'render');
|
|
673
674
|
const geometry = flags.geometry !== undefined;
|
|
675
|
+
const extremes = flags.extremes !== undefined;
|
|
674
676
|
// The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
|
|
675
677
|
// neither, or for --geometry alone, gets the bytes it always got: geometry.json is the pose, and a reading derived
|
|
676
678
|
// from it is a second file beside it, written only when asked for.
|
|
@@ -843,11 +845,12 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
843
845
|
// the export's grid is this frame set's by construction.
|
|
844
846
|
// The bones ride along for --geometry and --angle-range, the whole attachments for --geometry and the two readings off them.
|
|
845
847
|
const wantsBones = geometry || anglePairs !== undefined;
|
|
846
|
-
const wantsGeometry = geometry || folds || passPairs !== undefined || seamPairs !== undefined;
|
|
848
|
+
const wantsGeometry = extremes || geometry || folds || passPairs !== undefined || seamPairs !== undefined;
|
|
847
849
|
// The physics constraints each frame's step stepped ride along whenever the rig declares one (issue #1467): the
|
|
848
850
|
// `physics:` line and frames.json's `physics` field are read off them.
|
|
849
851
|
const sampling = {
|
|
850
852
|
...pose,
|
|
853
|
+
...(extremes ? { channels: true } : {}),
|
|
851
854
|
...(wantsBones ? { bones: true } : {}),
|
|
852
855
|
...(wantsGeometry ? { geometry: true } : {}),
|
|
853
856
|
...(physicsDeclared.length > 0 ? { physics: true } : {}),
|
|
@@ -856,6 +859,36 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
856
859
|
only === undefined
|
|
857
860
|
? sampleAll(poser, fps, sampling)
|
|
858
861
|
: new Map([[only, sampleAnimation(poser, only, fps, sampling)]]);
|
|
862
|
+
const extremeReadings = new Map<string, { reading: ReturnType<typeof extremesOf>; plate: Plate | null }>();
|
|
863
|
+
if (extremes) {
|
|
864
|
+
// Setup is an optional observation too: a failed extra read cannot turn a drawable set into a gate failure.
|
|
865
|
+
let setup: Frame | undefined;
|
|
866
|
+
let setupFailure: string | undefined;
|
|
867
|
+
for (const [name, frames] of sampled) {
|
|
868
|
+
let reading: ReturnType<typeof extremesOf> | undefined;
|
|
869
|
+
try {
|
|
870
|
+
reading = extremesOf(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, frames, pose);
|
|
871
|
+
let plate: Plate | null = null;
|
|
872
|
+
if (reading.geometryPair !== null) {
|
|
873
|
+
if (setupFailure !== undefined) throw new Error(setupFailure);
|
|
874
|
+
if (setup === undefined) {
|
|
875
|
+
try {
|
|
876
|
+
setup = sampleSetupPose(poser, pose)[0];
|
|
877
|
+
if (setup === undefined) throw new Error('setup pose sample is absent');
|
|
878
|
+
} catch (err) {
|
|
879
|
+
setupFailure = `setup pose observation failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
880
|
+
throw new Error(setupFailure);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
plate = extremesPlate(reading, setup, frames, pages, viewport);
|
|
884
|
+
}
|
|
885
|
+
extremeReadings.set(name, { reading, plate });
|
|
886
|
+
} catch (err) {
|
|
887
|
+
const refused = extremesRefusal(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, frames, err instanceof Error ? err.message : String(err), reading);
|
|
888
|
+
extremeReadings.set(name, { reading: refused, plate: null });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
859
892
|
// Every file's text before the first write, so a refused number leaves the
|
|
860
893
|
// output directory as it was rather than half of a frame set behind it.
|
|
861
894
|
const geometryTexts = new Map<string, string>();
|
|
@@ -1002,9 +1035,9 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1002
1035
|
}
|
|
1003
1036
|
}
|
|
1004
1037
|
}
|
|
1005
|
-
return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks };
|
|
1038
|
+
return { viewport, fitted, sampled, extremeReadings, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks };
|
|
1006
1039
|
});
|
|
1007
|
-
const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks } = posed.value;
|
|
1040
|
+
const { viewport, fitted, sampled, extremeReadings, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks } = posed.value;
|
|
1008
1041
|
console.log(` .. poser ${posed.note}`);
|
|
1009
1042
|
if (environment !== undefined) {
|
|
1010
1043
|
const response = environmentResponse(skeletonText);
|
|
@@ -1056,6 +1089,13 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1056
1089
|
if (foldReading !== undefined) writeFileSync(join(dir, FOLDS_FILE), foldsText(foldReading));
|
|
1057
1090
|
const passReading = passReadings.get(name);
|
|
1058
1091
|
if (passReading !== undefined) writeFileSync(join(dir, PASS_THROUGH_FILE), passThroughText(passReading));
|
|
1092
|
+
const extreme = extremeReadings.get(name);
|
|
1093
|
+
if (extreme !== undefined) {
|
|
1094
|
+
writeFileSync(join(dir, 'extremes.json'), extremesText(extreme.reading));
|
|
1095
|
+
if (extreme.plate === null) rmSync(join(dir, 'extremes.png'), { force: true });
|
|
1096
|
+
else extreme.plate.writePng(join(dir, 'extremes.png'));
|
|
1097
|
+
console.log(` .. ${extremesLine(extreme.reading)}`);
|
|
1098
|
+
}
|
|
1059
1099
|
const seamReading = seamReadings.get(name);
|
|
1060
1100
|
if (seamReading !== undefined) writeFileSync(join(dir, SEAM_FILE), seamText(seamReading));
|
|
1061
1101
|
const overlapReading = overlapReadings.get(name);
|
package/src/cli/shared.ts
CHANGED
|
@@ -152,7 +152,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
|
|
|
152
152
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
153
153
|
* halves together by reading `--help` rather than by naming a flag.
|
|
154
154
|
*/
|
|
155
|
-
const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
155
|
+
const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'extremes', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
158
|
* The flags a command is allowed to spell more than once.
|
|
@@ -1066,6 +1066,7 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1066
1066
|
'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 ' +
|
|
1067
1067
|
'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 ' +
|
|
1068
1068
|
"author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Not with --slot/--hide",
|
|
1069
|
+
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',
|
|
1069
1070
|
seam:
|
|
1070
1071
|
`also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
|
|
1071
1072
|
'per pair, the frames with a gap — background inside the hull of the band the two drawings share at the setup pose, carried to the frame by both — ' +
|
|
@@ -1756,7 +1757,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1756
1757
|
runtime: false,
|
|
1757
1758
|
spineFormat: false,
|
|
1758
1759
|
usage: [
|
|
1759
|
-
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
|
|
1760
|
+
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--extremes] [--poser core|spine] [--out render/]',
|
|
1760
1761
|
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
1761
1762
|
'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
|
|
1762
1763
|
'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
|
|
@@ -1766,7 +1767,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1766
1767
|
'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
|
|
1767
1768
|
'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
|
|
1768
1769
|
],
|
|
1769
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'poser', 'out'],
|
|
1770
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'poser', 'out'],
|
|
1770
1771
|
overrides: {
|
|
1771
1772
|
'page-edges': {
|
|
1772
1773
|
meaning:
|
package/src/core/animation.ts
CHANGED
|
@@ -1033,7 +1033,7 @@ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines,
|
|
|
1033
1033
|
const records = pathDeformed(doc, posedRecords(setupRecords, constraints, t), timelines, t, plant);
|
|
1034
1034
|
// A path constraint may read a slot bone the runtime has not yet brought up to date in this pass, as the previous pass left it (`./constraints_path.ts`, *Which slot bone*); `poseAnimations` holds that pass to the setup pose's reading.
|
|
1035
1035
|
const previous = setupRecords.some((r) => r.kind === 'path') ? applyConstraints(doc.bones, (plant.evaluate ?? worldTransforms)(doc.bones, active), active, plant.constraints ? plant.constraints(setupRecords) : setupRecords, null, undefined, undefined, undefined, solverRules(plant.solver)) : null;
|
|
1036
|
-
world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, undefined, solverRules(plant.solver));
|
|
1036
|
+
world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, plant.readLocals === undefined ? undefined : (state) => plant.readLocals?.(state.bones, sliders ?? []), solverRules(plant.solver));
|
|
1037
1037
|
if (stage !== undefined) stage.end('constraints');
|
|
1038
1038
|
}
|
|
1039
1039
|
for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
|
|
@@ -1066,7 +1066,7 @@ function steppedLocals(doc: CompiledDocument, bones: ModelBone[], plant: Timelin
|
|
|
1066
1066
|
stage.begin('constraints');
|
|
1067
1067
|
}
|
|
1068
1068
|
const stepped = stepPhysicsRecords(records(), physicsKeyed, t, active, step.ctx, step.before);
|
|
1069
|
-
const world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, undefined, solverRules(plant.solver));
|
|
1069
|
+
const world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, plant.readLocals === undefined ? undefined : (state) => plant.readLocals?.(state.bones, sliders ?? []), solverRules(plant.solver));
|
|
1070
1070
|
if (stage !== undefined) stage.end('constraints');
|
|
1071
1071
|
for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
|
|
1072
1072
|
return { bones, world, active, round: plant.round ?? gridRound };
|
package/src/core/index.ts
CHANGED
|
@@ -1085,6 +1085,8 @@ export interface StageHooks {
|
|
|
1085
1085
|
* channel misread — and nothing else passes any.
|
|
1086
1086
|
*/
|
|
1087
1087
|
export interface CorePlant {
|
|
1088
|
+
/** Optional read of the solved local channels and applied slider mappings; never modifies the pose. */
|
|
1089
|
+
readLocals?: (bones: readonly ModelBone[], sliders: readonly SliderApplication[]) => void;
|
|
1088
1090
|
evaluate?: SetupEvaluator;
|
|
1089
1091
|
shown?: ShownResolver;
|
|
1090
1092
|
colour?: ColourReader;
|
|
@@ -1335,7 +1337,7 @@ export function poseSetup(doc: CompiledDocument, plant: CorePlant = {}, physics?
|
|
|
1335
1337
|
if (bonesWhy === null) {
|
|
1336
1338
|
const active = activeBones(doc);
|
|
1337
1339
|
const records = constraintRecords(doc);
|
|
1338
|
-
setupWorld = applyConstraints(doc.bones, evaluate(doc.bones, active), active, plant.constraints ? plant.constraints(records) : records, null, applied, physics, undefined, solverRules(plant.solver));
|
|
1340
|
+
setupWorld = applyConstraints(doc.bones, evaluate(doc.bones, active), active, plant.constraints ? plant.constraints(records) : records, null, applied, physics, plant.readLocals === undefined ? undefined : (state) => plant.readLocals?.(state.bones, applied), solverRules(plant.solver));
|
|
1339
1341
|
const world = setupWorld;
|
|
1340
1342
|
bones = doc.bones.map((b): CoreBoneRow => {
|
|
1341
1343
|
const t = world.get(b.name);
|
package/src/extremes.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** render --extremes: observations on one already-sampled set, never a quality gate. */
|
|
2
|
+
import { Plate } from '../tools/plate.ts';
|
|
3
|
+
import { hypot2 } from './numeric.ts';
|
|
4
|
+
import { attachmentSource } from './render_channels.ts';
|
|
5
|
+
import { renderFrame, TRANSPARENT_BACKGROUND, type Frame, type PoseOptions, type Viewport } from './render_shared.ts';
|
|
6
|
+
|
|
7
|
+
export class ExtremesError extends Error {}
|
|
8
|
+
export interface ExtremeFrame { index: number; time: number }
|
|
9
|
+
export interface ChannelExtreme {
|
|
10
|
+
path: string[];
|
|
11
|
+
min: ExtremeFrame & { value: number; reason: string };
|
|
12
|
+
max: ExtremeFrame & { value: number; reason: string };
|
|
13
|
+
}
|
|
14
|
+
export interface ExtremesReading {
|
|
15
|
+
spec: 'rigc-extremes/1';
|
|
16
|
+
animation: string | null;
|
|
17
|
+
fps: number;
|
|
18
|
+
grid: ExtremeFrame[];
|
|
19
|
+
method: {
|
|
20
|
+
channels: string;
|
|
21
|
+
geometry: string;
|
|
22
|
+
ties: string;
|
|
23
|
+
geometryScope: string;
|
|
24
|
+
panels: string;
|
|
25
|
+
diff: string;
|
|
26
|
+
};
|
|
27
|
+
channels: ChannelExtreme[];
|
|
28
|
+
channelStatus: 'READ' | 'PARTIAL' | 'SKIP';
|
|
29
|
+
channelRefusals: Array<{ path: string[] | null; reason: string }>;
|
|
30
|
+
status: 'READ' | 'SKIP';
|
|
31
|
+
reason: string;
|
|
32
|
+
geometryPair: { frames: [ExtremeFrame, ExtremeFrame]; maxVertexDistance: number; witness: { slot: string; attachment: string; vertex: number } } | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const stamp = (f: Frame): ExtremeFrame => ({ index: f.index, time: f.time });
|
|
36
|
+
|
|
37
|
+
/** Channel validity is independent of geometry and of an image. A bad series cannot erase another series. */
|
|
38
|
+
function channelExtrema(frames: readonly Frame[]): Pick<ExtremesReading, 'channels' | 'channelStatus' | 'channelRefusals'> {
|
|
39
|
+
const channels = new Map<string, ChannelExtreme>();
|
|
40
|
+
const refusals = new Map<string, { path: string[] | null; reason: string }>();
|
|
41
|
+
const sources = new Map<string, object>();
|
|
42
|
+
const refuse = (path: string[] | null, why: string): void => {
|
|
43
|
+
const key = JSON.stringify(path);
|
|
44
|
+
channels.delete(key);
|
|
45
|
+
refusals.set(key, { path, reason: `EXTREMES_CHANNEL_REFUSED: ${why}` });
|
|
46
|
+
};
|
|
47
|
+
for (const f of frames) {
|
|
48
|
+
if (f.channelError !== undefined || f.channels === undefined) {
|
|
49
|
+
// A failed whole-frame read leaves every series' extrema unknown on this grid.
|
|
50
|
+
return { channels: [], channelStatus: 'SKIP', channelRefusals: [{ path: null, reason: `EXTREMES_CHANNEL_REFUSED: frame ${f.index}: ${f.channelError ?? 'local channels were not sampled'}` }] };
|
|
51
|
+
}
|
|
52
|
+
for (const sample of f.channels) {
|
|
53
|
+
const { path, value } = sample;
|
|
54
|
+
const key = JSON.stringify(path);
|
|
55
|
+
if (refusals.has(key)) continue;
|
|
56
|
+
if (!Number.isFinite(value)) { refuse(path, `frame ${f.index}, channel ${key}: found ${value}, required finite`); continue; }
|
|
57
|
+
if (path[0] === 'deform') {
|
|
58
|
+
const source = attachmentSource(sample);
|
|
59
|
+
if (source === undefined) { refuse(path, `frame ${f.index}, deform channel ${key}: original source identity unavailable`); continue; }
|
|
60
|
+
const before = sources.get(key);
|
|
61
|
+
if (before !== undefined && before !== source) {
|
|
62
|
+
refuse(path, `frame ${f.index}, deform channel ${key}: one slot/display-name/coordinate names different original attachment sources; separate extrema under that name are unavailable`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
sources.set(key, source);
|
|
66
|
+
}
|
|
67
|
+
const read = channels.get(key);
|
|
68
|
+
if (read === undefined) {
|
|
69
|
+
channels.set(key, { path,
|
|
70
|
+
min: { ...stamp(f), value, reason: 'minimum supplied local channel value on this grid; earliest tie; deform/slider samples only where applicable' },
|
|
71
|
+
max: { ...stamp(f), value, reason: 'maximum supplied local channel value on this grid; earliest tie; deform/slider samples only where applicable' },
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
if (value < read.min.value) read.min = { ...read.min, ...stamp(f), value };
|
|
75
|
+
if (value > read.max.value) read.max = { ...read.max, ...stamp(f), value };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const byKey = <T>(rows: Map<string, T>): T[] => [...rows].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, row]) => row);
|
|
80
|
+
return { channels: byKey(channels), channelStatus: refusals.size === 0 ? 'READ' : channels.size === 0 ? 'SKIP' : 'PARTIAL', channelRefusals: byKey(refusals) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Original attachment identity, independent of draw order and of a clipper's triangles. */
|
|
84
|
+
function geometryPair(frames: readonly Frame[], subset?: Pick<PoseOptions, 'slots' | 'hidden'>): ExtremesReading['geometryPair'] {
|
|
85
|
+
const selected = (slot: string): boolean => (subset?.slots === undefined || subset.slots.includes(slot)) && !subset?.hidden?.includes(slot);
|
|
86
|
+
const geometry = frames.map((f) => {
|
|
87
|
+
if (f.attachments === undefined) throw new ExtremesError(`frame ${f.index}: original attachment geometry was not sampled`);
|
|
88
|
+
const attachments = f.attachments.filter((a) => selected(a.slot));
|
|
89
|
+
for (const a of attachments) if (attachmentSource(a) === undefined) {
|
|
90
|
+
throw new ExtremesError(`frame ${f.index}, slot ${JSON.stringify(a.slot)}, attachment ${JSON.stringify(a.attachment)}: original source identity unavailable; required stable original-vertex correspondence`);
|
|
91
|
+
}
|
|
92
|
+
const byId = new Map(attachments.map((a) => [JSON.stringify([a.slot, a.attachment]), a]));
|
|
93
|
+
return [...byId].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
94
|
+
});
|
|
95
|
+
const first = geometry[0] ?? [];
|
|
96
|
+
for (let i = 0; i < geometry.length; i++) {
|
|
97
|
+
const row = geometry[i];
|
|
98
|
+
if (row.length !== first.length || row.some(([key, a], k) => key !== first[k]?.[0] || a.vertices.length !== first[k]?.[1].vertices.length || attachmentSource(a) !== attachmentSource(first[k][1]))) {
|
|
99
|
+
throw new ExtremesError(`frame ${frames[i].index}: attachment identity or vertex count changed (including actual source identity); cannot compare corresponding original vertices (expected ${first.map(([k, a]) => `${k}:${a.vertices.length / 2}`).join(', ')}, found ${row.map(([k, a]) => `${k}:${a.vertices.length / 2}`).join(', ')})`);
|
|
100
|
+
}
|
|
101
|
+
for (const [, a] of row) {
|
|
102
|
+
if (a.vertices.length % 2 || a.vertices.some((v) => !Number.isFinite(v))) throw new ExtremesError(`frame ${frames[i].index}, slot ${JSON.stringify(a.slot)}, attachment ${JSON.stringify(a.attachment)}: expected finite x/y vertex pairs`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let best = 0;
|
|
106
|
+
let pair: ExtremesReading['geometryPair'] = null;
|
|
107
|
+
for (let i = 0; i < frames.length; i++) for (let j = i + 1; j < frames.length; j++) {
|
|
108
|
+
for (let k = 0; k < first.length; k++) {
|
|
109
|
+
const a = geometry[i][k][1];
|
|
110
|
+
const b = geometry[j][k][1];
|
|
111
|
+
for (let v = 0; v < a.vertices.length; v += 2) {
|
|
112
|
+
const distance = hypot2(a.vertices[v] - b.vertices[v], a.vertices[v + 1] - b.vertices[v + 1]);
|
|
113
|
+
if (!Number.isFinite(distance)) throw new ExtremesError(`frames ${frames[i].index}/${frames[j].index}, slot ${JSON.stringify(a.slot)} vertex ${v / 2}: world distance is not finite`);
|
|
114
|
+
if (distance > best) {
|
|
115
|
+
best = distance;
|
|
116
|
+
pair = { frames: [stamp(frames[i]), stamp(frames[j])], maxVertexDistance: distance, witness: { slot: a.slot, attachment: a.attachment, vertex: v / 2 } };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return pair;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function extremesOf(animation: string | null, fps: number, frames: readonly Frame[], subset?: Pick<PoseOptions, 'slots' | 'hidden'>): ExtremesReading {
|
|
125
|
+
const observedChannels = channelExtrema(frames);
|
|
126
|
+
let pair: ExtremesReading['geometryPair'] = null;
|
|
127
|
+
let geometryFailure: string | undefined;
|
|
128
|
+
try { pair = geometryPair(frames, subset); }
|
|
129
|
+
catch (err) { geometryFailure = `EXTREMES_OBSERVATION_REFUSED: geometry: ${err instanceof Error ? err.message : String(err)}`; }
|
|
130
|
+
return {
|
|
131
|
+
spec: 'rigc-extremes/1', animation, fps, grid: frames.map(stamp),
|
|
132
|
+
method: {
|
|
133
|
+
channels: 'Sampled posed local bone rotate, translate.x/y, scale.x/y, shear.x/y (including held components), deform offsets per coordinate, and actually applied slider mapped-time; channelStatus/channelRefusals describe independent failures. Deform series with one display-name path but different original sources are refused individually; independent of geometry pair. No key-time interpolation outside this grid.',
|
|
134
|
+
geometry: 'Exhaustive frame pairs i<j; distance=max over corresponding original attachment vertices of hypot2(dx,dy)=sqrt(dx*dx+dy*dy), dx/dy in world units (the shared deterministic length helper).',
|
|
135
|
+
ties: 'First in frame-grid order; geometry ties keep earliest (i,j), then attachment identity in UTF-16 code-unit order, then vertex index. Timestamps are copied from sampled frames.',
|
|
136
|
+
geometryScope: 'Original region/mesh vertices, before clipping, occlusion and tint alpha; filtered explicitly by --slot/--hide. Changing attachment source identity/count is refused, including different sources with equal display names and vertex counts.',
|
|
137
|
+
panels: 'setup | min | max | abs(max-min) | abs(min-setup) | abs(max-setup). min/max label earlier/later frame of the geometry pair, not joint channel minima/maxima. Native viewport size and scale, legend appended below.',
|
|
138
|
+
diff: 'L=round(max(abs(Ra*Aa/255-Rb*Ab/255),abs(Ga*Aa/255-Gb*Ab/255),abs(Ba*Aa/255-Bb*Ab/255),abs(Aa-Ab))); opaque (L,L,L,255) on black. No amplification or scaling.',
|
|
139
|
+
},
|
|
140
|
+
...observedChannels,
|
|
141
|
+
status: pair === null ? 'SKIP' : 'READ',
|
|
142
|
+
reason: geometryFailure ?? (pair === null ? 'no motion in corresponding selected original vertices on this frame grid' : 'farthest geometry pair on this frame grid; see method and witness'),
|
|
143
|
+
geometryPair: pair,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A refused observation is a named SKIP, never a failed render. */
|
|
148
|
+
export function extremesRefusal(animation: string | null, fps: number, frames: readonly Frame[], reason: string, prior?: ExtremesReading): ExtremesReading {
|
|
149
|
+
const reading = prior ?? { ...extremesOf(animation, fps, []), ...channelExtrema(frames) };
|
|
150
|
+
return { ...reading, grid: frames.map(stamp), status: 'SKIP', reason: `EXTREMES_OBSERVATION_REFUSED: image: ${reason}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function extremesText(reading: ExtremesReading): string { return JSON.stringify(reading, null, 2) + '\n'; }
|
|
154
|
+
export function extremesLine(reading: ExtremesReading): string {
|
|
155
|
+
const name = JSON.stringify(reading.animation ?? 'setup-pose');
|
|
156
|
+
const p = reading.geometryPair;
|
|
157
|
+
const channels = `; channels ${reading.channelStatus} (${reading.channels.length} valid, ${reading.channelRefusals.length} refused)`;
|
|
158
|
+
return reading.status === 'SKIP' || p === null ? `EXTREMES ${name} SKIP: ${reading.reason}${channels}` : `EXTREMES ${name} geometry pair f${p.frames[0].index} @ ${p.frames[0].time}s / f${p.frames[1].index} @ ${p.frames[1].time}s; max vertex distance ${p.maxVertexDistance} world units${channels}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Alpha participates as a fourth premultiplied channel; RGB at alpha zero contributes zero. */
|
|
162
|
+
export function premultipliedDiff(a: Plate, b: Plate): Plate {
|
|
163
|
+
if (a.width !== b.width || a.height !== b.height) throw new ExtremesError('diff dimensions differ; required identical native pixel grids');
|
|
164
|
+
const out = new Plate(a.width, a.height);
|
|
165
|
+
for (let i = 0; i < out.data.length; i += 4) {
|
|
166
|
+
let d = Math.abs(a.data[i + 3] - b.data[i + 3]);
|
|
167
|
+
for (let c = 0; c < 3; c++) d = Math.max(d, Math.abs(a.data[i + c] * a.data[i + 3] / 255 - b.data[i + c] * b.data[i + 3] / 255));
|
|
168
|
+
out.data[i] = out.data[i + 1] = out.data[i + 2] = Math.round(d);
|
|
169
|
+
out.data[i + 3] = 255;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function extremesPlate(reading: ExtremesReading, setup: Frame, frames: readonly Frame[], pages: Map<string, Plate>, viewport: Viewport): Plate | null {
|
|
175
|
+
const pair = reading.geometryPair;
|
|
176
|
+
if (reading.status === 'SKIP' || pair === null) return null;
|
|
177
|
+
const find = (index: number): Frame => {
|
|
178
|
+
const f = frames.find((f) => f.index === index);
|
|
179
|
+
if (f === undefined) throw new ExtremesError(`chosen frame ${index} is absent`);
|
|
180
|
+
return f;
|
|
181
|
+
};
|
|
182
|
+
const base = renderFrame(setup, pages, viewport, TRANSPARENT_BACKGROUND);
|
|
183
|
+
const min = renderFrame(find(pair.frames[0].index), pages, viewport, TRANSPARENT_BACKGROUND);
|
|
184
|
+
const max = renderFrame(find(pair.frames[1].index), pages, viewport, TRANSPARENT_BACKGROUND);
|
|
185
|
+
const panels = [base, min, max, premultipliedDiff(max, min), premultipliedDiff(min, base), premultipliedDiff(max, base)];
|
|
186
|
+
const width = viewport.width * 6;
|
|
187
|
+
const legend = `1 setup | 2 min=f${pair.frames[0].index} (${pair.frames[0].time}s) | 3 max=f${pair.frames[1].index} (${pair.frames[1].time}s) | 4 abs(max-min) | 5 abs(min-setup) | 6 abs(max-setup). Geometry pair ordered by frame; channels listed separately in JSON. Diff: max premultiplied RGBA incl alpha, luminance on black, no amplification. Scale ${viewport.scale} px/world-unit.`;
|
|
188
|
+
const columns = Math.max(1, Math.floor(width / 6));
|
|
189
|
+
const rows = Math.ceil(legend.length / columns);
|
|
190
|
+
const out = new Plate(width, viewport.height + rows * 9 + 4);
|
|
191
|
+
// Legend black, all pixels opaque there; pictures retain their native alpha.
|
|
192
|
+
for (let i = viewport.height * width * 4 + 3; i < out.data.length; i += 4) out.data[i] = 255;
|
|
193
|
+
panels.forEach((panel, k) => {
|
|
194
|
+
for (let y = 0; y < viewport.height; y++) out.data.set(panel.data.subarray(y * panel.width * 4, (y + 1) * panel.width * 4), (y * width + k * panel.width) * 4);
|
|
195
|
+
});
|
|
196
|
+
for (let row = 0; row < rows; row++) out.text(legend.slice(row * columns, (row + 1) * columns), 0, viewport.height + 2 + row * 9, 1, [255, 255, 255, 255]);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localChannels, deformChannels, identifyAttachment } from './render_channels.ts';
|
|
1
2
|
/**
|
|
2
3
|
* The rasteriser — one code path for reference frames and for candidates.
|
|
3
4
|
*
|
|
@@ -118,6 +119,7 @@ import {
|
|
|
118
119
|
Skeleton,
|
|
119
120
|
SkeletonClipping,
|
|
120
121
|
SkeletonJson,
|
|
122
|
+
Slider,
|
|
121
123
|
TextureAtlas,
|
|
122
124
|
TextureAtlasRegion,
|
|
123
125
|
SkeletonData,
|
|
@@ -548,8 +550,21 @@ function spinePosed(skeleton: Skeleton): Posed {
|
|
|
548
550
|
return {
|
|
549
551
|
pieces: (draw) => drawPieces(skeleton, draw),
|
|
550
552
|
bones: () => boneSnapshots(skeleton),
|
|
551
|
-
attachments: () => attachmentsOf(skeleton),
|
|
553
|
+
attachments: (identity) => attachmentsOf(skeleton, identity),
|
|
552
554
|
physics: () => steppedPhysicsOf(skeleton),
|
|
555
|
+
channels: () => {
|
|
556
|
+
const values = localChannels(skeleton.bones.map((b) => {
|
|
557
|
+
const p = b.appliedPose;
|
|
558
|
+
return { name: b.data.name, rotation: p.rotation, x: p.x, y: p.y, scaleX: p.scaleX, scaleY: p.scaleY, shearX: p.shearX, shearY: p.shearY };
|
|
559
|
+
}), skeleton.constraints.filter((c): c is Slider => c instanceof Slider && c.active && c.appliedPose.mix !== 0 && (c.bone === null || c.bone.active)).map((c) => ({ name: c.data.name, time: c.appliedPose.time })));
|
|
560
|
+
for (const slot of skeleton.slots) {
|
|
561
|
+
const a = slot.appliedPose.attachment;
|
|
562
|
+
if (!(a instanceof MeshAttachment)) continue;
|
|
563
|
+
const setup = a.bones === null ? Array.from(a.vertices) : new Array<number>(a.vertices.length / 3 * 2).fill(0);
|
|
564
|
+
values.push(...deformChannels(slot.data.name, a.name, slot.appliedPose.deform, setup, a));
|
|
565
|
+
}
|
|
566
|
+
return values;
|
|
567
|
+
},
|
|
553
568
|
};
|
|
554
569
|
}
|
|
555
570
|
|
|
@@ -803,17 +818,18 @@ function worldVerticesOf(skeleton: Skeleton, slot: Slot, attachment: MeshAttachm
|
|
|
803
818
|
* Every slot's region or mesh attachment, whole, in the posed draw order — see
|
|
804
819
|
* `Frame.attachments`. No subset and no clip: those are `piecesOf`'s business.
|
|
805
820
|
*/
|
|
806
|
-
function attachmentsOf(skeleton: Skeleton): AttachmentPose[] {
|
|
821
|
+
function attachmentsOf(skeleton: Skeleton, identity = false): AttachmentPose[] {
|
|
807
822
|
const out: AttachmentPose[] = [];
|
|
808
823
|
for (const slot of skeleton.drawOrder.appliedPose) {
|
|
809
824
|
const attachment = slot.appliedPose.attachment;
|
|
810
825
|
if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
|
|
811
|
-
|
|
826
|
+
const row = {
|
|
812
827
|
slot: slot.data.name,
|
|
813
828
|
attachment: attachment.name,
|
|
814
829
|
vertices: worldVerticesOf(skeleton, slot, attachment),
|
|
815
830
|
color: tintOf(slot, attachment),
|
|
816
|
-
}
|
|
831
|
+
};
|
|
832
|
+
out.push(identity ? identifyAttachment(row, attachment) : row);
|
|
817
833
|
}
|
|
818
834
|
return out;
|
|
819
835
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Local scalar readings shared by the two posers; no stepping or inferred world decomposition. */
|
|
2
|
+
import type { ModelBone } from './model.ts';
|
|
3
|
+
import type { ChannelValue } from './render_shared.ts';
|
|
4
|
+
|
|
5
|
+
const FIELDS = {
|
|
6
|
+
rotation: ['rotate', 0], x: ['translate.x', 0], y: ['translate.y', 0],
|
|
7
|
+
scaleX: ['scale.x', 1], scaleY: ['scale.y', 1], shearX: ['shear.x', 0], shearY: ['shear.y', 0],
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export function localChannels(bones: readonly ModelBone[], sliders: readonly { name: string; time: number }[]): ChannelValue[] {
|
|
11
|
+
const out: ChannelValue[] = [];
|
|
12
|
+
for (const bone of bones) {
|
|
13
|
+
for (const field of Object.keys(FIELDS) as Array<keyof typeof FIELDS>) {
|
|
14
|
+
const [name, setup] = FIELDS[field];
|
|
15
|
+
out.push({ path: ['bone', bone.name, name], value: bone[field] ?? setup });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const slider of sliders) out.push({ path: ['slider', slider.name, 'mapped-time'], value: slider.time });
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Offsets from setup, x/y per influence for weighted meshes, x/y per vertex otherwise. */
|
|
23
|
+
export function deformChannels(slot: string, attachment: string, deform: readonly number[], setup: readonly number[], source?: object): ChannelValue[] {
|
|
24
|
+
return setup.map((v, i) => {
|
|
25
|
+
const read = { path: ['deform', slot, attachment, String(i)], value: deform.length === 0 ? 0 : deform[i] - v };
|
|
26
|
+
return source === undefined ? read : identifyAttachment(read, source);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The display name can be overridden by an export. Keep actual source identity out of serialized geometry.
|
|
31
|
+
const attachmentSources = new WeakMap<object, object>();
|
|
32
|
+
export function identifyAttachment<T extends object>(pose: T, source: object): T {
|
|
33
|
+
attachmentSources.set(pose, source);
|
|
34
|
+
return pose;
|
|
35
|
+
}
|
|
36
|
+
export function attachmentSource(pose: object): object | undefined { return attachmentSources.get(pose); }
|
package/src/render_core.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { localChannels, deformChannels, identifyAttachment } from './render_channels.ts';
|
|
2
|
+
import type { ChannelValue } from './render_shared.ts';
|
|
1
3
|
/**
|
|
2
4
|
* The render's second poser (issue #968, step 3d of issue #380): the posing
|
|
3
5
|
* seam of `src/render.ts` (`Poser` / `Posed`) implemented over rigc's own
|
|
@@ -462,7 +464,7 @@ function clipCover(pose: RawPose, active: ReadonlySet<string>): Map<string, Clip
|
|
|
462
464
|
}
|
|
463
465
|
|
|
464
466
|
/** One posed moment of the core, read through the seam's `Posed`. */
|
|
465
|
-
function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper, stepped: readonly string[]): Posed {
|
|
467
|
+
function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper, stepped: readonly string[], channels?: ChannelValue[]): Posed {
|
|
466
468
|
const slotRows = new Map<string, CoreSlotRow>(pose.slots.map((row) => [row[0], row]));
|
|
467
469
|
let regions: Map<string, DrawnRegion> | null = null;
|
|
468
470
|
const regionsOf = (): Map<string, DrawnRegion> => {
|
|
@@ -504,8 +506,25 @@ function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper, stepp
|
|
|
504
506
|
scaleY: b.scaleY,
|
|
505
507
|
}) : inactiveBoneSnapshot(b.name));
|
|
506
508
|
},
|
|
507
|
-
attachments: (): AttachmentPose[] =>
|
|
508
|
-
pose.drawn.map((d) =>
|
|
509
|
+
attachments: (identity = false): AttachmentPose[] =>
|
|
510
|
+
pose.drawn.map((d) => {
|
|
511
|
+
const row = { slot: d.slot, attachment: d.attachment, vertices: [...d.vertices], color: tintOf(d) };
|
|
512
|
+
if (!identity) return row;
|
|
513
|
+
const source = pose.shown.find((s) => s.slot === d.slot)?.geometry;
|
|
514
|
+
if (source === undefined) throw new CoreInputError(`slot "${d.slot}" has no original attachment identity`);
|
|
515
|
+
return identifyAttachment(row, source);
|
|
516
|
+
}),
|
|
517
|
+
...(channels === undefined ? {} : { channels: (): ChannelValue[] => {
|
|
518
|
+
const out = [...channels];
|
|
519
|
+
for (const shown of pose.shown) {
|
|
520
|
+
const g = shown.geometry;
|
|
521
|
+
const v = g.kind === 'mesh' ? g.vertices : g.kind === 'linkedmesh' ? sourceOfDoc(input.doc)(g.skin, g.slot, g.source) : null;
|
|
522
|
+
if (v === null || typeof v === 'string') continue;
|
|
523
|
+
const setup = v.weighted ? new Array<number>(v.bindings.reduce((n, b) => n + b.length * 2, 0)).fill(0) : v.xy;
|
|
524
|
+
out.push(...deformChannels(shown.slot, shown.name, shown.deform ?? [], setup, g));
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
} }),
|
|
509
528
|
physics: (): string[] => [...stepped],
|
|
510
529
|
};
|
|
511
530
|
}
|
|
@@ -756,11 +775,16 @@ export function corePoser(
|
|
|
756
775
|
bones: doc.bones.map((b) => ({ name: b.name, parent: b.parent ?? null })),
|
|
757
776
|
slots: doc.slots.map((s) => ({ name: s.name, bone: s.bone })),
|
|
758
777
|
subset: (opts, skin) => subsetOver(roster, opts, skin),
|
|
759
|
-
setup: (skin) => {
|
|
778
|
+
setup: (skin, channels) => {
|
|
760
779
|
const input = inputOf(skin);
|
|
761
|
-
|
|
780
|
+
let values: ChannelValue[] | undefined;
|
|
781
|
+
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
782
|
+
const pose = poseRawSetup(input.doc, { through, ...observe });
|
|
783
|
+
return corePosed(input, pose, through, [], values);
|
|
762
784
|
},
|
|
763
|
-
animation: (name, skin, fps, count, visit, environment) => {
|
|
785
|
+
animation: (name, skin, fps, count, visit, environment, channels) => {
|
|
786
|
+
let values: ChannelValue[] | undefined;
|
|
787
|
+
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
764
788
|
const input = inputOf(skin);
|
|
765
789
|
const step = 1 / fps;
|
|
766
790
|
// Each pose is drawn as it is posed and released before the next (issue #1180), so the walk holds one pose and not
|
|
@@ -778,12 +802,12 @@ export function corePoser(
|
|
|
778
802
|
if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
|
|
779
803
|
return moved;
|
|
780
804
|
};
|
|
781
|
-
walk(input.doc, name, new Array<number>(count).fill(step), { through, physicsStep }, (pose, i) => {
|
|
805
|
+
walk(input.doc, name, new Array<number>(count).fill(step), { through, physicsStep, ...observe }, (pose, i) => {
|
|
782
806
|
const stepped = [...steppedNow].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
783
807
|
steppedNow.clear();
|
|
784
808
|
if (drawFailed.length > 0) return;
|
|
785
809
|
try {
|
|
786
|
-
visit(i, corePosed(input, pose, through, stepped));
|
|
810
|
+
visit(i, corePosed(input, pose, through, stepped, values));
|
|
787
811
|
} catch (thrown) {
|
|
788
812
|
drawFailed.push(thrown);
|
|
789
813
|
}
|
package/src/render_shared.ts
CHANGED
|
@@ -415,6 +415,8 @@ export interface UvWindow {
|
|
|
415
415
|
|
|
416
416
|
/** Options for `piecesOf` and the samplers that call it. */
|
|
417
417
|
export interface PoseOptions {
|
|
418
|
+
/** Also read local bone/deform channels and mapped slider times, and retain original attachment source identity outside JSON, for --extremes. */
|
|
419
|
+
channels?: boolean;
|
|
418
420
|
/** Also record each piece's original-art UVs — see `PieceTexture`. */
|
|
419
421
|
texture?: boolean;
|
|
420
422
|
/**
|
|
@@ -595,7 +597,13 @@ export interface ClipSource {
|
|
|
595
597
|
/** One drawable in a posed frame. */
|
|
596
598
|
export type Piece = Quad | Mesh;
|
|
597
599
|
|
|
600
|
+
export interface ChannelValue { path: string[]; value: number }
|
|
601
|
+
|
|
598
602
|
export interface Frame {
|
|
603
|
+
/** Sampled local channels, only when requested. */
|
|
604
|
+
channels?: ChannelValue[];
|
|
605
|
+
/** A failed optional observation does not fail the draw. */
|
|
606
|
+
channelError?: string;
|
|
599
607
|
/** Index within the sampled sequence — the number in `f0000.png`. */
|
|
600
608
|
index: number;
|
|
601
609
|
time: number;
|
|
@@ -1107,12 +1115,14 @@ export interface DrawOptions {
|
|
|
1107
1115
|
* what it needs before the next frame is posed.
|
|
1108
1116
|
*/
|
|
1109
1117
|
export interface Posed {
|
|
1118
|
+
/** Optional channel read, on the very pose drawn; no extra stepping. */
|
|
1119
|
+
channels?(): ChannelValue[];
|
|
1110
1120
|
/** The drawables in draw order — see `piecesOf` for the walk and the clip. */
|
|
1111
1121
|
pieces(draw: DrawOptions): Piece[];
|
|
1112
1122
|
/** Every bone's world transform, in the skeleton's declaration order. */
|
|
1113
1123
|
bones(): BoneSnapshot[];
|
|
1114
|
-
/** Every slot's region or mesh attachment, whole, in the
|
|
1115
|
-
attachments(): AttachmentPose[];
|
|
1124
|
+
/** Every slot's region or mesh attachment, whole, in draw order; identity=true retains the original source in an out-of-JSON registry for --extremes. */
|
|
1125
|
+
attachments(identity?: boolean): AttachmentPose[];
|
|
1116
1126
|
/**
|
|
1117
1127
|
* The physics constraints the step that posed this moment stepped, by name in
|
|
1118
1128
|
* code-unit order (issue #1467). A constraint is stepped when, after a step
|
|
@@ -1143,14 +1153,14 @@ export interface Poser {
|
|
|
1143
1153
|
/** `slotSubsetOf` against this skeleton posed under `skin` — refused by `SlotSubsetError`. */
|
|
1144
1154
|
subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
|
|
1145
1155
|
/** The setup pose under `skin` (absent: no skin set at all). */
|
|
1146
|
-
setup(skin: string | undefined): Posed;
|
|
1156
|
+
setup(skin: string | undefined, channels?: boolean): Posed;
|
|
1147
1157
|
/**
|
|
1148
1158
|
* Animation `name` under `skin`, not looping: `visit(i, posed)` for every
|
|
1149
1159
|
* `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
|
|
1150
1160
|
* `environment(i)`, when given, is the change to the world the physics
|
|
1151
1161
|
* answers set before the step that poses frame `i` (issue #1416).
|
|
1152
1162
|
*/
|
|
1153
|
-
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined): void;
|
|
1163
|
+
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean): void;
|
|
1154
1164
|
/** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
|
|
1155
1165
|
rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
|
|
1156
1166
|
}
|
|
@@ -1188,12 +1198,21 @@ function drawResolver(poser: Poser, opts: PoseOptions | undefined): () => DrawOp
|
|
|
1188
1198
|
|
|
1189
1199
|
/** One frame off one posed moment, with what `opts` asked to record beside the pieces. */
|
|
1190
1200
|
function frameOf(index: number, time: number, posed: Posed, draw: DrawOptions, opts: PoseOptions | undefined): Frame {
|
|
1201
|
+
let channels: Pick<Frame, 'channels' | 'channelError'> = {};
|
|
1202
|
+
if (opts?.channels) {
|
|
1203
|
+
try {
|
|
1204
|
+
if (posed.channels === undefined) throw new Error('poser does not expose local channels');
|
|
1205
|
+
channels = { channels: posed.channels() };
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) { channels = { channelError: `frame ${index}: local channel read failed: ${err instanceof Error ? err.message : String(err)}` }; }
|
|
1208
|
+
}
|
|
1191
1209
|
return {
|
|
1192
1210
|
index,
|
|
1193
1211
|
time,
|
|
1194
1212
|
pieces: posed.pieces(draw),
|
|
1213
|
+
...channels,
|
|
1195
1214
|
...(opts?.bones ? { bones: posed.bones() } : {}),
|
|
1196
|
-
...(opts?.geometry ? { attachments: posed.attachments() } : {}),
|
|
1215
|
+
...(opts?.geometry ? { attachments: opts.channels ? posed.attachments(true) : posed.attachments() } : {}),
|
|
1197
1216
|
...(opts?.physics ? { physics: posed.physics() } : {}),
|
|
1198
1217
|
};
|
|
1199
1218
|
}
|
|
@@ -1220,7 +1239,8 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
|
|
|
1220
1239
|
frames.push(frameOf(i, i * step, posed, draw(), opts));
|
|
1221
1240
|
};
|
|
1222
1241
|
// Without an environment the call is the one it always was, so a poser that takes none is asked nothing new.
|
|
1223
|
-
if (opts?.
|
|
1242
|
+
if (opts?.channels) poser.animation(name, opts.skin, fps, count, visit, opts.environment === undefined ? undefined : environmentAt(opts.environment, fps), true);
|
|
1243
|
+
else if (opts?.environment === undefined) poser.animation(name, opts?.skin, fps, count, visit);
|
|
1224
1244
|
else poser.animation(name, opts.skin, fps, count, visit, environmentAt(opts.environment, fps));
|
|
1225
1245
|
return frames;
|
|
1226
1246
|
}
|
|
@@ -1235,7 +1255,7 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
|
|
|
1235
1255
|
*/
|
|
1236
1256
|
export function sampleSetupPose(source: PoseSource, opts?: PoseOptions): Frame[] {
|
|
1237
1257
|
const poser = poserOf(source);
|
|
1238
|
-
const posed = poser.setup(opts?.skin);
|
|
1258
|
+
const posed = opts?.channels ? poser.setup(opts.skin, true) : poser.setup(opts?.skin);
|
|
1239
1259
|
return [frameOf(0, 0, posed, drawResolver(poser, opts)(), opts)];
|
|
1240
1260
|
}
|
|
1241
1261
|
|