@rig-c/core 2.79.0 → 2.81.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -18
- package/docs/AUTHORING.md +106 -0
- package/package.json +1 -1
- package/skills/rigc/SKILL.md +5 -5
- 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 +42 -4
- package/src/render_channels.ts +36 -0
- package/src/render_core.ts +32 -8
- package/src/render_shared.ts +27 -7
- package/tools/editor_roundtrip.ts +87 -3
package/README.md
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
</p>
|
|
4
4
|
|
|
5
5
|
<p align="center">
|
|
6
|
-
<a href="https://www.npmjs.com/package
|
|
6
|
+
<a href="https://www.npmjs.com/package/@rig-c/core"><img src="https://img.shields.io/npm/v/@rig-c/core.svg?style=flat-square&color=FF6B4A" alt="npm version" /></a>
|
|
7
7
|
<a href="https://www.npmjs.com/package/rig-c"><img src="https://img.shields.io/npm/dm/rig-c.svg?style=flat-square&color=A855F7" alt="npm downloads" /></a>
|
|
8
8
|
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-38BDF8.svg?style=flat-square" alt="license" /></a>
|
|
9
9
|
</p>
|
|
@@ -14,9 +14,9 @@ format, with rigc's own compiled model beside it —
|
|
|
14
14
|
gated by a list of named assertions before a byte is written — rigc's own validator in
|
|
15
15
|
the published package, held to a `spine-core` round trip's verdicts in this
|
|
16
16
|
repository's CI. Built so AI agents can author rigs and check their own work; it ships
|
|
17
|
-
as an agent skill.
|
|
18
|
-
|
|
19
|
-
published
|
|
17
|
+
as an agent skill. Its canonical npm package is `@rig-c/core`, under the toolchain's
|
|
18
|
+
scope. `rig-c` is the convenience install name; `spine-rigc` is the legacy install
|
|
19
|
+
name. Both remain published alongside it with the same product files.
|
|
20
20
|
|
|
21
21
|
## What you get
|
|
22
22
|
|
|
@@ -177,22 +177,23 @@ name with exit 2 before upload. Use a published exact `X.Y.Z` (no `v` prefix,
|
|
|
177
177
|
alias, range or prerelease), or retry a failed network fetch. This is repository
|
|
178
178
|
verification tooling, not an installed `rigc` command or a change to CI's matrix.
|
|
179
179
|
|
|
180
|
-
**The npm package is
|
|
180
|
+
**The canonical npm package is `@rig-c/core`; the command it installs is `rigc`.**
|
|
181
|
+
`rig-c` remains a convenience install name. npm
|
|
181
182
|
refuses the name `rigc` as too similar to packages that already exist, so the
|
|
182
|
-
project and the executable keep their name
|
|
183
|
-
hyphen; the repository is [`rig-c/core`](https://github.com/rig-c/core). **`spine-rigc` is the same package**: it is the name
|
|
183
|
+
project and the executable keep their name; the convenience name `rig-c` carries one
|
|
184
|
+
hyphen; the repository is [`rig-c/core`](https://github.com/rig-c/core). **`spine-rigc` is the legacy install name for the same package**: it is the name
|
|
184
185
|
every version up to 2.20.4 shipped under, and it stays published as an alias —
|
|
185
186
|
every version from 2.20.4 on is on both names, with the same files — so a
|
|
186
187
|
project that depends on `spine-rigc` keeps receiving every release. A new
|
|
187
|
-
project installs
|
|
188
|
+
project installs `@rig-c/core`, the canonical package under the toolchain's scope.
|
|
188
189
|
|
|
189
190
|
```bash
|
|
190
|
-
bunx rig-c --help # run it without installing
|
|
191
|
-
bun add -g rig-c # or install the command
|
|
192
|
-
bun add -d rig-c # or pin it in a project
|
|
191
|
+
bunx @rig-c/core --help # run it without installing
|
|
192
|
+
bun add -g @rig-c/core # or install the command
|
|
193
|
+
bun add -d @rig-c/core # or pin it in a project
|
|
193
194
|
```
|
|
194
195
|
|
|
195
|
-
`npx rig-c` works too, as long as Bun is on `PATH` — the executable is a
|
|
196
|
+
`npx @rig-c/core` works too, as long as Bun is on `PATH` — the executable is a
|
|
196
197
|
Bun script, and npm only writes the shim that calls it.
|
|
197
198
|
|
|
198
199
|
Installed, the command is `rigc`. The examples below spell it `bun cli.ts`
|
|
@@ -242,7 +243,7 @@ plugin marketplace:
|
|
|
242
243
|
/plugin install rigc@rigc
|
|
243
244
|
```
|
|
244
245
|
|
|
245
|
-
With the package already installed, `claude --plugin-dir node_modules
|
|
246
|
+
With the package already installed, `claude --plugin-dir node_modules/@rig-c/core`
|
|
246
247
|
loads the same skills without a marketplace. The plugin carries no version of its
|
|
247
248
|
own — `/plugin update` follows `main` commit by commit, and the only version on
|
|
248
249
|
disk stays the one in `package.json`.
|
|
@@ -254,8 +255,8 @@ Codex, Gemini CLI and Antigravity read skills from one directory in the workspac
|
|
|
254
255
|
`node_modules`. With the package installed, one command puts every skill there:
|
|
255
256
|
|
|
256
257
|
```shell
|
|
257
|
-
bun add -d rig-c
|
|
258
|
-
bun rigc skills install # relative links: .agents/skills/rigc -> ../../node_modules
|
|
258
|
+
bun add -d @rig-c/core
|
|
259
|
+
bun rigc skills install # relative links: .agents/skills/rigc -> ../../node_modules/@rig-c/core/skills/rigc
|
|
259
260
|
bun rigc skills install --copy # the folders themselves, for a host that does not follow a link
|
|
260
261
|
```
|
|
261
262
|
|
|
@@ -290,11 +291,11 @@ reference export, so nothing you read in a quickstart is an answer to anything
|
|
|
290
291
|
**1. Install the command.**
|
|
291
292
|
|
|
292
293
|
```bash
|
|
293
|
-
bun add -g rig-c # installs `rigc`
|
|
294
|
+
bun add -g @rig-c/core # installs `rigc`
|
|
294
295
|
```
|
|
295
296
|
|
|
296
297
|
Or skip the install and prefix every command below with `bunx `, e.g.
|
|
297
|
-
`bunx rig-c build …`.
|
|
298
|
+
`bunx @rig-c/core build …`.
|
|
298
299
|
|
|
299
300
|
**2. Make a directory and three plates.** rigc measures PNGs rather than trusting
|
|
300
301
|
a number you typed (R5), so the art has to exist. These three are solid colours a
|
|
@@ -646,7 +647,7 @@ commands take it and what its default is.
|
|
|
646
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 |
|
|
647
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)) |
|
|
648
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 |
|
|
649
|
-
| `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 |
|
|
650
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` |
|
|
651
652
|
| `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
|
|
652
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
|
|
@@ -9464,6 +9467,88 @@ bounds plus its `2`-px border on each side exceed `2048` (`w + 4 > 2048` or
|
|
|
9464
9467
|
|
|
9465
9468
|
---
|
|
9466
9469
|
|
|
9470
|
+
### 8.11 Min, max and diff before a person looks — `render --extremes`
|
|
9471
|
+
|
|
9472
|
+
```sh
|
|
9473
|
+
bun cli.ts render --candidate path/to/build --extremes [--animation turn] [--fps 12] [--max 256]
|
|
9474
|
+
```
|
|
9475
|
+
|
|
9476
|
+
Look at **min · max · diff before a person does**. A baked line carried by a
|
|
9477
|
+
moving part can be obvious in the difference image while a seam metric misses
|
|
9478
|
+
it. This flag makes the picture; it does not classify tears or ghosts and does
|
|
9479
|
+
not change the seam metric.
|
|
9480
|
+
|
|
9481
|
+
Each set gets `extremes.json` and one `EXTREMES` line. A moving set also gets
|
|
9482
|
+
`extremes.png`, with six panels from left to right:
|
|
9483
|
+
**setup | min | max | abs(max-min) | abs(min-setup) | abs(max-setup)**.
|
|
9484
|
+
Every panel has the render's native viewport width, height and scale; the legend
|
|
9485
|
+
is appended below, wrapping its text without shrinking any picture. The three
|
|
9486
|
+
pose panels are transparent renders. Each diff pixel is opaque greyscale on
|
|
9487
|
+
black, with luminance
|
|
9488
|
+
`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)))`.
|
|
9489
|
+
Alpha participates; RGB behind alpha zero contributes zero. There is no contrast
|
|
9490
|
+
amplification or additional scaling.
|
|
9491
|
+
|
|
9492
|
+
The JSON keeps two different observations separate:
|
|
9493
|
+
|
|
9494
|
+
- **`channels`**: every sampled local bone rotation, componentwise translation,
|
|
9495
|
+
signed scale and shear, deform offset coordinate (per influence for weighted
|
|
9496
|
+
meshes), and each applied slider's mapped time, with its minimum and maximum,
|
|
9497
|
+
exact frame timestamp and selection reason. Held bone components are included
|
|
9498
|
+
explicitly. A slider applies its own mapping; its time is read from the pose,
|
|
9499
|
+
rather than guessed from elapsed time. A mix-zero slider or one whose driver
|
|
9500
|
+
bone is inactive did not apply a mapping and contributes no stale time.
|
|
9501
|
+
- **`geometryPair`**: exhaustively compare frame pairs on the set's own grid.
|
|
9502
|
+
For a pair, measure the Euclidean world distance of each corresponding original
|
|
9503
|
+
attachment vertex with the shared deterministic `sqrt(dx*dx+dy*dy)` length
|
|
9504
|
+
helper, and take the greatest. Select the pair with the greatest
|
|
9505
|
+
such distance. Record that maximum in world units and its slot, attachment and
|
|
9506
|
+
vertex witness. `min` and `max` in the picture mean the earlier and later
|
|
9507
|
+
frame of **this pair**; they are not a pose combining every channel's minima
|
|
9508
|
+
or maxima. The channel table can choose entirely different frames.
|
|
9509
|
+
|
|
9510
|
+
`channelStatus` (`READ`, `PARTIAL` or `SKIP`) and `channelRefusals` describe
|
|
9511
|
+
channel validity separately from the picture's `status` and `reason`. A geometry
|
|
9512
|
+
correspondence failure or failed optional image read **preserves every valid
|
|
9513
|
+
independent channel extremum**; an image failure also retains a valid geometry
|
|
9514
|
+
pair if one was measured. A non-finite scalar refuses only that channel. If a
|
|
9515
|
+
whole frame's channel reader failed, extrema on the full grid are unknown and
|
|
9516
|
+
the channel table is a named SKIP while geometry can still be read.
|
|
9517
|
+
|
|
9518
|
+
Deform samples are offsets from their original source, read only where that
|
|
9519
|
+
posed attachment supplies the coordinate. A deform path names slot, attachment
|
|
9520
|
+
display name and coordinate. If two different original sources share that path,
|
|
9521
|
+
its extrema cannot be represented unambiguously under the name: refuse that
|
|
9522
|
+
series by name in `channelRefusals`, preserving unrelated bone, slider and other
|
|
9523
|
+
deform series. Missing original deform-source identity is refused the same way;
|
|
9524
|
+
never merge unrelated coordinates because their names happen to match.
|
|
9525
|
+
|
|
9526
|
+
Ties keep the earliest frame for a channel and the earliest ordered frame pair
|
|
9527
|
+
for geometry, then the first attachment identity in UTF-16 code-unit order and
|
|
9528
|
+
vertex index. Times are copied from sampled frames. No continuous-time extremum
|
|
9529
|
+
between grid points is claimed. A single frame or a set with no selected vertex
|
|
9530
|
+
motion has `status: "SKIP"`, a named reason and no invented pair or PNG. Channels
|
|
9531
|
+
still describe the samples, including any local change leaving geometry still.
|
|
9532
|
+
|
|
9533
|
+
Both posers support `--slot` and `--hide`: resolve the flags normally, then filter
|
|
9534
|
+
original attachment geometry by those slot names. The viewport stays the whole
|
|
9535
|
+
rig's. This reads original vertices **before clipping, occlusion and tint alpha**;
|
|
9536
|
+
re-clipped triangles never supply cross-frame indices. An attachment switch,
|
|
9537
|
+
missing attachment or changed vertex count cannot establish correspondence.
|
|
9538
|
+
The flag also checks the actual original source identity: two different
|
|
9539
|
+
attachments can have the same display name and vertex count. That identity is
|
|
9540
|
+
kept outside serialized geometry so existing geometry files are unchanged.
|
|
9541
|
+
If the optional source identity was not captured, correspondence is unavailable
|
|
9542
|
+
and is refused by name too. When correspondence cannot be established, the
|
|
9543
|
+
set writes a named `EXTREMES_OBSERVATION_REFUSED` SKIP instead of comparing
|
|
9544
|
+
unrelated indices. Optional image failures preserve the independently collected
|
|
9545
|
+
channels. Channel failures are reported separately as described above.
|
|
9546
|
+
**Observation failures do not change a successful render's exit status.** Invalid CLI arguments retain their normal refusal semantics.
|
|
9547
|
+
|
|
9548
|
+
Without `--extremes` the existing frames and files are unchanged. The flag adds
|
|
9549
|
+
its observations alongside them. A SKIP removes a stale `extremes.png` for that
|
|
9550
|
+
set so an earlier pair cannot masquerade as the current reading.
|
|
9551
|
+
|
|
9467
9552
|
## 9. Checking against the frames — `rigc check`
|
|
9468
9553
|
|
|
9469
9554
|
```bash
|
|
@@ -11267,6 +11352,27 @@ Everything above is what the editor **does**. This is what it **returns** — wh
|
|
|
11267
11352
|
matters to you for one reason: a construct nobody has carried through the editor
|
|
11268
11353
|
is a construct that might vanish there, and an agent cannot see that it did.
|
|
11269
11354
|
|
|
11355
|
+
`tools/editor_roundtrip.ts` also names a clipping semantic change after export
|
|
11356
|
+
(issue #1630), including under `--exported`. If the export adds `convex: true`
|
|
11357
|
+
where the same skin/slot/attachment in the build did not state true, and the
|
|
11358
|
+
build's authored setup polygon has a reflex vertex, it prints:
|
|
11359
|
+
|
|
11360
|
+
```text
|
|
11361
|
+
FAIL EDITOR_ADDED_CONVEX_ON_CONCAVE_CLIP skin "S" slot "C" attachment "A" end "E": export convex=true, build convex is not true; setup reflex vertices=N; the editor's export clips with the convex hull of the authored concave polygon
|
|
11362
|
+
```
|
|
11363
|
+
|
|
11364
|
+
The setup polygon is read through the runtime with that skin applied, setup
|
|
11365
|
+
bones and constraints posed, and the slot's deform empty, as for the renderer's
|
|
11366
|
+
authored rest geometry. Weighted vertex runs are therefore read as posed points,
|
|
11367
|
+
not mistaken for coordinate pairs; animation-time deformation is not substituted
|
|
11368
|
+
for the authored setup shape. Reflex turns are counted in either winding and
|
|
11369
|
+
straight boundary vertices do not make a convex polygon concave. A convex setup
|
|
11370
|
+
polygon or a build already stating `convex: true` adds no line. The named failure
|
|
11371
|
+
makes the trip exit nonzero **after** the existing per-skin render/check figures
|
|
11372
|
+
are printed; it does not replace those measurements. It changes neither compiler
|
|
11373
|
+
emission nor coordinates, and does not imply that removing the export's flag
|
|
11374
|
+
undoes the editor's other changes.
|
|
11375
|
+
|
|
11270
11376
|
🔬 The source is one run: eight discriminator rigs, each built to isolate a group
|
|
11271
11377
|
of fields, compiled by rigc **0.21.0** (emitting 4.3.13), imported into a licensed
|
|
11272
11378
|
Spine **4.3.26** through the documented CLI and exported back, with the
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.81.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/skills/rigc/SKILL.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: rigc
|
|
3
3
|
description: Author, build and validate Spine 4.3 skeleton data (skeleton.json plus its .atlas) from loose part PNGs with rigc, the rig compiler that verifies its own output with named assertions before writing it. Use for any request to make a Spine rig or Spine animation from PNG parts, or where the source is a Live2D, Unity or video model whose pictures you can render, to run or read rigc build, validate, render, preview, check or vote, or to write or fix a *.rig.json or *.motion.json spec; it says which shipped guide to open for the need at hand. Not for Live2D conversion, cutting an illustration into parts, or real-time face tracking.
|
|
4
4
|
license: MIT
|
|
5
|
-
compatibility: Requires Bun 1.4 or later. The
|
|
5
|
+
compatibility: Requires Bun 1.4 or later. The canonical npm package is @rig-c/core (bunx @rig-c/core, or bun add -d @rig-c/core); the command it installs is rigc. rig-c is the convenience install name; spine-rigc is the legacy install name.
|
|
6
6
|
---
|
|
7
7
|
|
|
8
8
|
# rigc — a rig compiler for agents
|
|
@@ -46,8 +46,8 @@ index within that same limit; only legacy uncoded library errors point at `§5.1
|
|
|
46
46
|
## Install
|
|
47
47
|
|
|
48
48
|
```shell
|
|
49
|
-
bunx rig-c --help # run it without installing
|
|
50
|
-
bun add -d rig-c # or pin it in the project; the command is `rigc`
|
|
49
|
+
bunx @rig-c/core --help # run it without installing
|
|
50
|
+
bun add -d @rig-c/core # or pin it in the project; the command is `rigc`
|
|
51
51
|
bun rigc skills install # then link these skills into .agents/skills
|
|
52
52
|
```
|
|
53
53
|
|
|
@@ -145,8 +145,8 @@ spec files field by field, the emission rules, the loop, and the failure map. Th
|
|
|
145
145
|
| a **skeleton.json somebody else authored** — read it, repair it, extend it | [INGEST.md](https://github.com/rig-c/core/blob/main/docs/INGEST.md) | `rigc-ingest` |
|
|
146
146
|
| you are the **person operating** the agent rather than the agent | [PROMPTING.md](https://github.com/rig-c/core/blob/main/docs/PROMPTING.md) | — |
|
|
147
147
|
|
|
148
|
-
Every guide linked here is in the installed package at `node_modules
|
|
148
|
+
Every guide linked here is in the installed package at `node_modules/@rig-c/core/docs/`,
|
|
149
149
|
which is the copy that matches the rigc you run; the links go to the repository's
|
|
150
150
|
`main`. Inside the Claude Code plugin the same files are at `${CLAUDE_PLUGIN_ROOT}/docs/`.
|
|
151
151
|
Formats, the CLI reference and the licence chain:
|
|
152
|
-
[README.md](https://github.com/rig-c/core/blob/main/README.md), installed at `node_modules
|
|
152
|
+
[README.md](https://github.com/rig-c/core/blob/main/README.md), installed at `node_modules/@rig-c/core/README.md`.
|
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
|
|
|
@@ -581,6 +596,28 @@ function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
|
|
|
581
596
|
return skeleton;
|
|
582
597
|
}
|
|
583
598
|
|
|
599
|
+
/** A named clip's authored setup polygon under its own skin, with no deform. */
|
|
600
|
+
export function setupClipPolygon(
|
|
601
|
+
data: SkeletonData,
|
|
602
|
+
skin: string,
|
|
603
|
+
slotName: string,
|
|
604
|
+
attachmentName: string,
|
|
605
|
+
): number[] {
|
|
606
|
+
const skeleton = setupPosed(data, skin);
|
|
607
|
+
const index = data.findSlot(slotName)?.index ?? -1;
|
|
608
|
+
const slot = skeleton.slots[index];
|
|
609
|
+
const attachment = data.findSkin(skin)?.getAttachment(index, attachmentName);
|
|
610
|
+
if (slot === undefined || !(attachment instanceof ClippingAttachment)) {
|
|
611
|
+
throw new Error(
|
|
612
|
+
`setup clip skin ${JSON.stringify(skin)} slot ${JSON.stringify(slotName)} attachment ${JSON.stringify(attachmentName)} did not resolve`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
slot.appliedPose.deform.length = 0;
|
|
616
|
+
const polygon = new Array<number>(attachment.worldVerticesLength).fill(0);
|
|
617
|
+
attachment.computeWorldVertices(skeleton, slot, 0, attachment.worldVerticesLength, polygon, 0, 2);
|
|
618
|
+
return polygon;
|
|
619
|
+
}
|
|
620
|
+
|
|
584
621
|
/**
|
|
585
622
|
* Both posers for the skeleton at `skeletonPath` drawn through the atlas at
|
|
586
623
|
* `atlasPath`, with spine-core's parse of it already in hand (`data`) — the
|
|
@@ -781,17 +818,18 @@ function worldVerticesOf(skeleton: Skeleton, slot: Slot, attachment: MeshAttachm
|
|
|
781
818
|
* Every slot's region or mesh attachment, whole, in the posed draw order — see
|
|
782
819
|
* `Frame.attachments`. No subset and no clip: those are `piecesOf`'s business.
|
|
783
820
|
*/
|
|
784
|
-
function attachmentsOf(skeleton: Skeleton): AttachmentPose[] {
|
|
821
|
+
function attachmentsOf(skeleton: Skeleton, identity = false): AttachmentPose[] {
|
|
785
822
|
const out: AttachmentPose[] = [];
|
|
786
823
|
for (const slot of skeleton.drawOrder.appliedPose) {
|
|
787
824
|
const attachment = slot.appliedPose.attachment;
|
|
788
825
|
if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
|
|
789
|
-
|
|
826
|
+
const row = {
|
|
790
827
|
slot: slot.data.name,
|
|
791
828
|
attachment: attachment.name,
|
|
792
829
|
vertices: worldVerticesOf(skeleton, slot, attachment),
|
|
793
830
|
color: tintOf(slot, attachment),
|
|
794
|
-
}
|
|
831
|
+
};
|
|
832
|
+
out.push(identity ? identifyAttachment(row, attachment) : row);
|
|
795
833
|
}
|
|
796
834
|
return out;
|
|
797
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
|
|
|
@@ -917,6 +917,75 @@ export function skinRoster(
|
|
|
917
917
|
};
|
|
918
918
|
}
|
|
919
919
|
|
|
920
|
+
/** An attachment's declaration key, kept separate from its skin and slot. */
|
|
921
|
+
interface ClipFlagSkin {
|
|
922
|
+
name: string;
|
|
923
|
+
attachments?: Record<string, Record<string, { type?: string; convex?: boolean; end?: string }>>;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Reflex turns of the world's own doubles, winding independent; straight turns are not reflex. */
|
|
927
|
+
function reflexVertices(polygon: readonly number[]): number {
|
|
928
|
+
const n = polygon.length / 2;
|
|
929
|
+
let area2 = 0;
|
|
930
|
+
for (let i = 0; i < n; i++) {
|
|
931
|
+
const j = (i + 1) % n;
|
|
932
|
+
area2 += polygon[2 * i] * polygon[2 * j + 1] - polygon[2 * j] * polygon[2 * i + 1];
|
|
933
|
+
}
|
|
934
|
+
const winding = Math.sign(area2);
|
|
935
|
+
if (winding === 0) return 0; // Degenerate geometry is the validator's refusal.
|
|
936
|
+
let count = 0;
|
|
937
|
+
for (let i = 0; i < n; i++) {
|
|
938
|
+
const j = (i + 1) % n;
|
|
939
|
+
const k = (i + 2) % n;
|
|
940
|
+
const turn =
|
|
941
|
+
(polygon[2 * j] - polygon[2 * i]) * (polygon[2 * k + 1] - polygon[2 * j + 1]) -
|
|
942
|
+
(polygon[2 * j + 1] - polygon[2 * i + 1]) * (polygon[2 * k] - polygon[2 * j]);
|
|
943
|
+
if (turn !== 0 && Math.sign(turn) !== winding) count++;
|
|
944
|
+
}
|
|
945
|
+
return count;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
/**
|
|
949
|
+
* An editor-added convex flag changes a concave clip's meaning (issue #1630).
|
|
950
|
+
* Compare the same skin/slot/attachment, then read the BUILD's posed authored
|
|
951
|
+
* setup polygon. Weighted runs are not points; an animation deform is not the
|
|
952
|
+
* authored setup shape. Empty deform follows the renderer's `restOf` convention.
|
|
953
|
+
* Imports are lazy so the existing no-editor/trial/read refusals need no runtime.
|
|
954
|
+
*/
|
|
955
|
+
async function addedConvexClipFailures(build: string, exported: string, buildDir: string): Promise<string[]> {
|
|
956
|
+
const before = (JSON.parse(readFileSync(build, 'utf8')) as { skins?: ClipFlagSkin[] }).skins;
|
|
957
|
+
const after = (JSON.parse(readFileSync(exported, 'utf8')) as { skins?: ClipFlagSkin[] }).skins;
|
|
958
|
+
const candidates: Array<{ skin: string; slot: string; attachment: string; end: string | null }> = [];
|
|
959
|
+
for (const skin of Array.isArray(after) ? after : []) {
|
|
960
|
+
const originalSkin = (Array.isArray(before) ? before : []).find((s) => s.name === skin.name);
|
|
961
|
+
for (const [slot, attachments] of Object.entries(skin.attachments ?? {})) {
|
|
962
|
+
for (const [attachment, clip] of Object.entries(attachments)) {
|
|
963
|
+
const original = originalSkin?.attachments?.[slot]?.[attachment];
|
|
964
|
+
if (clip.type === 'clipping' && clip.convex === true && original?.type === 'clipping' && original.convex !== true) {
|
|
965
|
+
candidates.push({ skin: skin.name, slot, attachment, end: original.end ?? null });
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
if (candidates.length === 0) return [];
|
|
971
|
+
const { posableFromText, setupClipPolygon } = await import('../src/render.ts');
|
|
972
|
+
const atlas = readdirSync(buildDir).find((name) => name.endsWith('.atlas'))!;
|
|
973
|
+
const { data } = posableFromText(readFileSync(build, 'utf8'), readFileSync(join(buildDir, atlas), 'utf8'), buildDir);
|
|
974
|
+
const lines: string[] = [];
|
|
975
|
+
for (const candidate of candidates) {
|
|
976
|
+
const polygon = setupClipPolygon(data, candidate.skin, candidate.slot, candidate.attachment);
|
|
977
|
+
const reflex = reflexVertices(polygon);
|
|
978
|
+
if (reflex > 0) {
|
|
979
|
+
lines.push(
|
|
980
|
+
` FAIL EDITOR_ADDED_CONVEX_ON_CONCAVE_CLIP skin ${JSON.stringify(candidate.skin)} slot ${JSON.stringify(candidate.slot)} ` +
|
|
981
|
+
`attachment ${JSON.stringify(candidate.attachment)} end ${JSON.stringify(candidate.end)}: export convex=true, build convex is not true; ` +
|
|
982
|
+
`setup reflex vertices=${reflex}; the editor's export clips with the convex hull of the authored concave polygon`,
|
|
983
|
+
);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
return lines;
|
|
987
|
+
}
|
|
988
|
+
|
|
920
989
|
/**
|
|
921
990
|
* One measure of a `diff --json` report as `matched/total (ratio)`, or `null`
|
|
922
991
|
* when the report or the measure is not there — read off the report for the
|
|
@@ -1185,7 +1254,7 @@ function imagesProbeFault(skeleton: Record<string, unknown>): string | null {
|
|
|
1185
1254
|
return null;
|
|
1186
1255
|
}
|
|
1187
1256
|
|
|
1188
|
-
function main(): void {
|
|
1257
|
+
async function main(): Promise<void> {
|
|
1189
1258
|
const opts = parseArgs(process.argv.slice(2));
|
|
1190
1259
|
const rigc = rigcCommand();
|
|
1191
1260
|
|
|
@@ -1489,6 +1558,21 @@ function main(): void {
|
|
|
1489
1558
|
// console layout for them would break the first time that layout is tidied.
|
|
1490
1559
|
for (const line of diffSummaryLines(diffJson)) emit(` ${line}`);
|
|
1491
1560
|
|
|
1561
|
+
// Report the changed meaning without skipping any later per-skin figures.
|
|
1562
|
+
let clipFailures: string[];
|
|
1563
|
+
try {
|
|
1564
|
+
// An unreadable export has no semantic comparison. Leave its established
|
|
1565
|
+
// gate/read failure intact (including the late-read planted controls).
|
|
1566
|
+
clipFailures = gate.status === 0 ? await addedConvexClipFailures(source, join(cand, 'skeleton.json'), opts.build) : [];
|
|
1567
|
+
} catch (error) {
|
|
1568
|
+
return fail(`could not measure clipping setup geometry: ${error instanceof Error ? error.message : String(error)}`);
|
|
1569
|
+
}
|
|
1570
|
+
if (clipFailures.length > 0) {
|
|
1571
|
+
emit('');
|
|
1572
|
+
emit('## 4.1 clipping semantics');
|
|
1573
|
+
for (const line of clipFailures) emit(line);
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1492
1576
|
// 🔒 The skins come off BOTH files, compared before anything is rendered
|
|
1493
1577
|
// (issue #801). A skin the export dropped is named here as LOST BY THE EXPORT
|
|
1494
1578
|
// and fails the run; enumerating the export's skins alone would quietly stop
|
|
@@ -1627,7 +1711,7 @@ function main(): void {
|
|
|
1627
1711
|
// is the half a single un-skinned check could not say.
|
|
1628
1712
|
// A version line that disagrees with the export is red too (issue #1199): the
|
|
1629
1713
|
// report would otherwise name an editor the trip did not run on, green.
|
|
1630
|
-
process.exit(gate.status === 0 && checksClean && rosterClean && !versionDisagrees ? 0 : 1);
|
|
1714
|
+
process.exit(gate.status === 0 && checksClean && rosterClean && !versionDisagrees && clipFailures.length === 0 ? 0 : 1);
|
|
1631
1715
|
}
|
|
1632
1716
|
|
|
1633
1717
|
// ⭐ Guarded so `shapeOf`, `shapeDiff`, `skinsDeclaredBy` and `skinBlocks` can be
|
|
@@ -1638,4 +1722,4 @@ function main(): void {
|
|
|
1638
1722
|
// reach either would be paying for a round trip to test arithmetic. Run as a
|
|
1639
1723
|
// program this is unchanged: `bun tools/editor_roundtrip.ts …` makes this module
|
|
1640
1724
|
// the entry, so `import.meta.main` is true.
|
|
1641
|
-
if (import.meta.main) main();
|
|
1725
|
+
if (import.meta.main) await main();
|