@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 CHANGED
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <a href="https://www.npmjs.com/package/rig-c"><img src="https://img.shields.io/npm/v/rig-c.svg?style=flat-square&color=FF6B4A" alt="npm version" /></a>
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. On npm it is `rig-c` — the same package as `spine-rigc`, the name it
18
- first shipped under, and as `@rig-c/core`, its name under the toolchain's scope, both
19
- published beside it with the same files at every version.
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 `rig-c`; the command it installs is `rigc`.** npm
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 and the registry entry carries one
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 `rig-c`; `@rig-c/core` is the same package under the scope.
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/rig-c`
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/rig-c/skills/rigc
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.79.0",
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": {
@@ -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 tool is the npm package rig-c (bunx rig-c, or bun add -d rig-c); the command it installs is rigc.
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/rig-c/docs/`,
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/rig-c/README.md`.
152
+ [README.md](https://github.com/rig-c/core/blob/main/README.md), installed at `node_modules/@rig-c/core/README.md`.
@@ -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:
@@ -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);
@@ -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
- out.push({
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); }
@@ -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) => ({ slot: d.slot, attachment: d.attachment, vertices: [...d.vertices], color: tintOf(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
- return corePosed(input, poseRawSetup(input.doc, { through }), through, []);
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
  }
@@ -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 posed draw order. */
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?.environment === undefined) poser.animation(name, opts?.skin, fps, count, visit);
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();