@rig-c/core 2.80.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
@@ -647,7 +647,7 @@ commands take it and what its default is.
647
647
  | `ingest <skeleton.json> --out <dir>` | `build` run backwards: reads a Spine 4.3 skeleton and writes the rig spec and motion spec that **rebuild it**, plus a findings report naming everything it could not carry. a skeleton that declares no stage is carried as declaring none, `--stage x,y,w,h` adds a box to one — and is refused, rather than ignored, beside one that declares a box — and `--images <dir>` writes the spec's own images directory — the opposite direction from `build --images`, which overrides it — so the rebuild carries no flag at all |
648
648
  | `split --rig … --motion … --slot <name> --out <dir>` | the answer to a mesh refused because its triangles are **several pieces that share no vertex** — two legs after the triangles bridging them were deleted. It reads the two specs and writes them back into an absent or empty `--out` with one mesh per piece: piece 1 stays in the slot, piece k gets a new slot `<slot>_<k>` after it, each with the original's uvs and weights unchanged, its triangles renumbered outline first where the hull rule needs it, and every deform key re-sliced to the new indices — plus `split.json`, where every original vertex and triangle went. One piece is said so and nothing is written; what it cannot carry is refused by name ([AUTHORING §3.4.11](docs/AUTHORING.md)) |
649
649
  | `explain --rig … --motion …` | the compiled rig as a table — every bone with its resolved parent, the slots in draw order, every timeline key by key. Writes nothing. What to reach for when a rig compiles and still looks wrong |
650
- | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box; `ghostA` and `ghostB` partition the same ghost by which named pair member drew it, each with its own frames, spans and worst; `gapOpen` and `gapCovered` partition `gap` by zero versus positive rendered alpha from the whole skeleton, including faint and clipped drawings ([AUTHORING §8.8](docs/AUTHORING.md#88-where-two-slots-stop-meeting--render---seam)) — a reading, not a gate; the exit status never moves. `--overlaps` adds an `overlaps.json` per set and prints one `OVERLAP "<under>" under "<over>"` line per pair of slots where the one drawn later covers the earlier one and the motion moves it — the frames, and the most pixels covered and the frame of them — with an outline plate per covered frame; the render reports what was drawn, and the draw-order key is the author's remedy — a reading, not a gate. `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene. `--sheet [--sheet-format json-hash] [--page-edges pot\|free]` also packs each set's straight-alpha transparent frames into `sheet.png` + `sheet.json` (TexturePacker JSON Hash with a shared world-origin `anchor` and `2`-px clamped edge extrusion, verified through `pixi.js@8.22.0` and `phaser@3.90.0` by `tools/sheet_readers.ts`) and `sheet-manifest.json` (`rigc-sheet/1`), leaving diagnostic `f####.png` and `contact.png` unchanged ([AUTHORING §8.10](docs/AUTHORING.md#810-sprite-sheets-from-a-render--render---sheet)) — a self-contained sprite object per animation set; when animations play and how a scene is composed remains the consumer's |
650
+ | `render --candidate <dir>` | PNG frames plus a contact sheet, in `render/`, one directory per set: `<anim>@<fps>fps/` rather than `<anim>/` at any rate but the default — `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`. `--hide <slot,…>` or `--slot <slot,…>` draws part of the rig on the **same grid** as the whole, so the two frames overlay and the difference is the part; `frames.json` records the subset and `check` refuses such a set as a reference. `--geometry` adds a `geometry.json` per set: every frame's bone world transforms and skinned attachment vertices, with the rest geometry, on the frames' own grid. `--folds` adds a `folds.json` per set and prints one line per mesh with a triangle turned over against the setup pose: the frames, the worst frame's count and which triangles, their world box and their setup-pose area. `--pass-through <cloth>:<leg>` adds a `pass-through.json` per set and prints the frames where a cloth drawn over a leg has its side inside the leg's drawn outline, with the depth and the world box — a reading, not a gate. `--seam <slot>:<slot>` adds a `seam.json` per set and prints, per pair, the frames where two slots that meet open background inside the joint (a gap) or show the overlap band of one past the other (a ghost), with the worst frame's cell count and world box; `ghostA` and `ghostB` partition the same ghost by which named pair member drew it, each with its own frames, spans and worst; `gapOpen` and `gapCovered` partition `gap` by zero versus positive rendered alpha from the whole skeleton, including faint and clipped drawings ([AUTHORING §8.8](docs/AUTHORING.md#88-where-two-slots-stop-meeting--render---seam)) — a reading, not a gate; the exit status never moves. `--overlaps` adds an `overlaps.json` per set and prints one `OVERLAP "<under>" under "<over>"` line per pair of slots where the one drawn later covers the earlier one and the motion moves it — the frames, and the most pixels covered and the frame of them — with an outline plate per covered frame; the render reports what was drawn, and the draw-order key is the author's remedy — a reading, not a gate. `--extremes` adds `extremes.json` and a native six-panel `extremes.png` (setup, geometry-pair min/max and three premultiplied RGBA diffs with a legend), plus one `EXTREMES` line per set. Per-channel minima/maxima and the farthest original-vertex geometry pair are distinct readings; valid channel extrema survive a geometry/image SKIP, with channel refusals reported separately; still or uncomparable geometry reports a named SKIP without changing render exit status. Look at min, max and diff before a person does ([AUTHORING §8.11](docs/AUTHORING.md#811-min-max-and-diff-before-a-person-looks--render---extremes)). `--angle-range <bone>:<relative-to>` prints how far one bone turns against another over each set: the lowest and highest angle and the frames they fall on — a reading, not a gate. `--viewport <x,y,w,h>` or `--viewport-from <frames.json>` draws over a given box instead of a fitted one, so two builds land on one pixel grid; a slot that reaches past it is named by set and frame, and `frames.json` records it. `--environment <schedule.json>` draws the rig under the wind and gravity its physics answers, in time — the same frames on the core and on spine-core; an inspection of the response the rig states, not a scene. `--sheet [--sheet-format json-hash] [--page-edges pot\|free]` also packs each set's straight-alpha transparent frames into `sheet.png` + `sheet.json` (TexturePacker JSON Hash with a shared world-origin `anchor` and `2`-px clamped edge extrusion, verified through `pixi.js@8.22.0` and `phaser@3.90.0` by `tools/sheet_readers.ts`) and `sheet-manifest.json` (`rigc-sheet/1`), leaving diagnostic `f####.png` and `contact.png` unchanged ([AUTHORING §8.10](docs/AUTHORING.md#810-sprite-sheets-from-a-render--render---sheet)) — a self-contained sprite object per animation set; when animations play and how a scene is composed remains the consumer's |
651
651
  | `preview --candidate <dir>` | one self-contained `.html` that plays it, headed by the line `validate <dir>` prints for it and the rigc version — a refused candidate is still previewed, and its header says so in the gate's words. Repeat `--candidate` for one page with a pane per candidate, in the order given; a green `build` ends by naming this command for its own `--out` |
652
652
  | `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` |
653
653
  | `pose --images <dir> --frame <png>` | reads part placements **out of** a picture |
package/docs/AUTHORING.md CHANGED
@@ -146,6 +146,9 @@ bun cli.ts check \
146
146
  # read the table → fix the spec → build again → check again
147
147
  # ↳ read its per-frame column before its MAE — §9.2
148
148
 
149
+ # Before a person looks: inspect each motion's min, max and diff (§8.11).
150
+ bun cli.ts render --candidate path/to/spine --extremes --out frames/
151
+
149
152
  # …and if nobody gave you frames, LOOK at it instead — neither needs a reference:
150
153
  bun cli.ts render --candidate path/to/spine # PNG frames + a contact sheet grid
151
154
  bun cli.ts preview --candidate path/to/spine # one .html that plays it in Spine's own player
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rig-c/core",
3
- "version": "2.80.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": {
@@ -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
 
@@ -803,17 +818,18 @@ function worldVerticesOf(skeleton: Skeleton, slot: Slot, attachment: MeshAttachm
803
818
  * Every slot's region or mesh attachment, whole, in the posed draw order — see
804
819
  * `Frame.attachments`. No subset and no clip: those are `piecesOf`'s business.
805
820
  */
806
- function attachmentsOf(skeleton: Skeleton): AttachmentPose[] {
821
+ function attachmentsOf(skeleton: Skeleton, identity = false): AttachmentPose[] {
807
822
  const out: AttachmentPose[] = [];
808
823
  for (const slot of skeleton.drawOrder.appliedPose) {
809
824
  const attachment = slot.appliedPose.attachment;
810
825
  if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
811
- out.push({
826
+ const row = {
812
827
  slot: slot.data.name,
813
828
  attachment: attachment.name,
814
829
  vertices: worldVerticesOf(skeleton, slot, attachment),
815
830
  color: tintOf(slot, attachment),
816
- });
831
+ };
832
+ out.push(identity ? identifyAttachment(row, attachment) : row);
817
833
  }
818
834
  return out;
819
835
  }
@@ -0,0 +1,36 @@
1
+ /** Local scalar readings shared by the two posers; no stepping or inferred world decomposition. */
2
+ import type { ModelBone } from './model.ts';
3
+ import type { ChannelValue } from './render_shared.ts';
4
+
5
+ const FIELDS = {
6
+ rotation: ['rotate', 0], x: ['translate.x', 0], y: ['translate.y', 0],
7
+ scaleX: ['scale.x', 1], scaleY: ['scale.y', 1], shearX: ['shear.x', 0], shearY: ['shear.y', 0],
8
+ } as const;
9
+
10
+ export function localChannels(bones: readonly ModelBone[], sliders: readonly { name: string; time: number }[]): ChannelValue[] {
11
+ const out: ChannelValue[] = [];
12
+ for (const bone of bones) {
13
+ for (const field of Object.keys(FIELDS) as Array<keyof typeof FIELDS>) {
14
+ const [name, setup] = FIELDS[field];
15
+ out.push({ path: ['bone', bone.name, name], value: bone[field] ?? setup });
16
+ }
17
+ }
18
+ for (const slider of sliders) out.push({ path: ['slider', slider.name, 'mapped-time'], value: slider.time });
19
+ return out;
20
+ }
21
+
22
+ /** Offsets from setup, x/y per influence for weighted meshes, x/y per vertex otherwise. */
23
+ export function deformChannels(slot: string, attachment: string, deform: readonly number[], setup: readonly number[], source?: object): ChannelValue[] {
24
+ return setup.map((v, i) => {
25
+ const read = { path: ['deform', slot, attachment, String(i)], value: deform.length === 0 ? 0 : deform[i] - v };
26
+ return source === undefined ? read : identifyAttachment(read, source);
27
+ });
28
+ }
29
+
30
+ // The display name can be overridden by an export. Keep actual source identity out of serialized geometry.
31
+ const attachmentSources = new WeakMap<object, object>();
32
+ export function identifyAttachment<T extends object>(pose: T, source: object): T {
33
+ attachmentSources.set(pose, source);
34
+ return pose;
35
+ }
36
+ export function attachmentSource(pose: object): object | undefined { return attachmentSources.get(pose); }
@@ -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