@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 +1 -1
- package/docs/AUTHORING.md +85 -0
- package/package.json +1 -1
- package/src/cli/core_commands.ts +44 -4
- package/src/cli/shared.ts +4 -3
- package/src/core/animation.ts +2 -2
- package/src/core/index.ts +3 -1
- package/src/extremes.ts +198 -0
- package/src/render.ts +20 -4
- package/src/render_channels.ts +36 -0
- package/src/render_core.ts +32 -8
- package/src/render_shared.ts +27 -7
package/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.
|
|
3
|
+
"version": "2.81.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { extremesOf, extremesText, extremesPlate, extremesLine, extremesRefusal } from '../extremes.ts';
|
|
1
2
|
/**
|
|
2
3
|
* The bodies of the commands that reach nothing of spine-core — `explain`,
|
|
3
4
|
* `ingest`, `diff`, `check`, `render`, `inspect`, `pose`, `chainfit` and `skills` —
|
|
@@ -44,7 +45,7 @@ import { buildSheetSet, SHEET_MANIFEST_FILE, type SheetSetPack, SheetUsageError,
|
|
|
44
45
|
import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText } from '../seam.ts';
|
|
45
46
|
import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
|
|
46
47
|
import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
|
|
47
|
-
import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, frameSetDir, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser, type Viewport, viewportFor } from '../render_shared.ts';
|
|
48
|
+
import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, frameSetDir, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, sampleSetupPose, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser, type Viewport, viewportFor } from '../render_shared.ts';
|
|
48
49
|
import { type CompileResult } from '../types.ts';
|
|
49
50
|
import { attachmentRegionJoins } from '../region_joins.ts';
|
|
50
51
|
import { cmdRepack } from './repack.ts';
|
|
@@ -671,6 +672,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
671
672
|
const sheetPageEdges = wantsSheet ? readPageEdges(flags) : undefined;
|
|
672
673
|
const outRoot = resolve(flags.out ?? 'render');
|
|
673
674
|
const geometry = flags.geometry !== undefined;
|
|
675
|
+
const extremes = flags.extremes !== undefined;
|
|
674
676
|
// The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
|
|
675
677
|
// neither, or for --geometry alone, gets the bytes it always got: geometry.json is the pose, and a reading derived
|
|
676
678
|
// from it is a second file beside it, written only when asked for.
|
|
@@ -843,11 +845,12 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
843
845
|
// the export's grid is this frame set's by construction.
|
|
844
846
|
// The bones ride along for --geometry and --angle-range, the whole attachments for --geometry and the two readings off them.
|
|
845
847
|
const wantsBones = geometry || anglePairs !== undefined;
|
|
846
|
-
const wantsGeometry = geometry || folds || passPairs !== undefined || seamPairs !== undefined;
|
|
848
|
+
const wantsGeometry = extremes || geometry || folds || passPairs !== undefined || seamPairs !== undefined;
|
|
847
849
|
// The physics constraints each frame's step stepped ride along whenever the rig declares one (issue #1467): the
|
|
848
850
|
// `physics:` line and frames.json's `physics` field are read off them.
|
|
849
851
|
const sampling = {
|
|
850
852
|
...pose,
|
|
853
|
+
...(extremes ? { channels: true } : {}),
|
|
851
854
|
...(wantsBones ? { bones: true } : {}),
|
|
852
855
|
...(wantsGeometry ? { geometry: true } : {}),
|
|
853
856
|
...(physicsDeclared.length > 0 ? { physics: true } : {}),
|
|
@@ -856,6 +859,36 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
856
859
|
only === undefined
|
|
857
860
|
? sampleAll(poser, fps, sampling)
|
|
858
861
|
: new Map([[only, sampleAnimation(poser, only, fps, sampling)]]);
|
|
862
|
+
const extremeReadings = new Map<string, { reading: ReturnType<typeof extremesOf>; plate: Plate | null }>();
|
|
863
|
+
if (extremes) {
|
|
864
|
+
// Setup is an optional observation too: a failed extra read cannot turn a drawable set into a gate failure.
|
|
865
|
+
let setup: Frame | undefined;
|
|
866
|
+
let setupFailure: string | undefined;
|
|
867
|
+
for (const [name, frames] of sampled) {
|
|
868
|
+
let reading: ReturnType<typeof extremesOf> | undefined;
|
|
869
|
+
try {
|
|
870
|
+
reading = extremesOf(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, frames, pose);
|
|
871
|
+
let plate: Plate | null = null;
|
|
872
|
+
if (reading.geometryPair !== null) {
|
|
873
|
+
if (setupFailure !== undefined) throw new Error(setupFailure);
|
|
874
|
+
if (setup === undefined) {
|
|
875
|
+
try {
|
|
876
|
+
setup = sampleSetupPose(poser, pose)[0];
|
|
877
|
+
if (setup === undefined) throw new Error('setup pose sample is absent');
|
|
878
|
+
} catch (err) {
|
|
879
|
+
setupFailure = `setup pose observation failed: ${err instanceof Error ? err.message : String(err)}`;
|
|
880
|
+
throw new Error(setupFailure);
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
plate = extremesPlate(reading, setup, frames, pages, viewport);
|
|
884
|
+
}
|
|
885
|
+
extremeReadings.set(name, { reading, plate });
|
|
886
|
+
} catch (err) {
|
|
887
|
+
const refused = extremesRefusal(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, frames, err instanceof Error ? err.message : String(err), reading);
|
|
888
|
+
extremeReadings.set(name, { reading: refused, plate: null });
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
}
|
|
859
892
|
// Every file's text before the first write, so a refused number leaves the
|
|
860
893
|
// output directory as it was rather than half of a frame set behind it.
|
|
861
894
|
const geometryTexts = new Map<string, string>();
|
|
@@ -1002,9 +1035,9 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1002
1035
|
}
|
|
1003
1036
|
}
|
|
1004
1037
|
}
|
|
1005
|
-
return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks };
|
|
1038
|
+
return { viewport, fitted, sampled, extremeReadings, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks };
|
|
1006
1039
|
});
|
|
1007
|
-
const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks } = posed.value;
|
|
1040
|
+
const { viewport, fitted, sampled, extremeReadings, geometryTexts, foldReadings, passReadings, seamReadings, angleReadings, overlapReadings, sheetPacks } = posed.value;
|
|
1008
1041
|
console.log(` .. poser ${posed.note}`);
|
|
1009
1042
|
if (environment !== undefined) {
|
|
1010
1043
|
const response = environmentResponse(skeletonText);
|
|
@@ -1056,6 +1089,13 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1056
1089
|
if (foldReading !== undefined) writeFileSync(join(dir, FOLDS_FILE), foldsText(foldReading));
|
|
1057
1090
|
const passReading = passReadings.get(name);
|
|
1058
1091
|
if (passReading !== undefined) writeFileSync(join(dir, PASS_THROUGH_FILE), passThroughText(passReading));
|
|
1092
|
+
const extreme = extremeReadings.get(name);
|
|
1093
|
+
if (extreme !== undefined) {
|
|
1094
|
+
writeFileSync(join(dir, 'extremes.json'), extremesText(extreme.reading));
|
|
1095
|
+
if (extreme.plate === null) rmSync(join(dir, 'extremes.png'), { force: true });
|
|
1096
|
+
else extreme.plate.writePng(join(dir, 'extremes.png'));
|
|
1097
|
+
console.log(` .. ${extremesLine(extreme.reading)}`);
|
|
1098
|
+
}
|
|
1059
1099
|
const seamReading = seamReadings.get(name);
|
|
1060
1100
|
if (seamReading !== undefined) writeFileSync(join(dir, SEAM_FILE), seamText(seamReading));
|
|
1061
1101
|
const overlapReading = overlapReadings.get(name);
|
package/src/cli/shared.ts
CHANGED
|
@@ -152,7 +152,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
|
|
|
152
152
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
153
153
|
* halves together by reading `--help` rather than by naming a flag.
|
|
154
154
|
*/
|
|
155
|
-
const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
155
|
+
const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'extremes', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
156
156
|
|
|
157
157
|
/**
|
|
158
158
|
* The flags a command is allowed to spell more than once.
|
|
@@ -1066,6 +1066,7 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1066
1066
|
'Each frame with a moving pair also gets an outline plate, overlaps-f<NNNN>.png: every slot in a moving pair as its contour in its own ' +
|
|
1067
1067
|
'colour, dashed where a slot drawn later covers it, named at its top. The render reports what was drawn; who should be in front is the ' +
|
|
1068
1068
|
"author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Not with --slot/--hide",
|
|
1069
|
+
extremes: 'also write extremes.json and a native six-panel extremes.png per set: setup, geometry-pair min/max and premultiplied RGBA diffs, with a legend. Per-channel extrema are recorded separately and valid channels survive geometry/image SKIPs; channel refusals are named separately. Still geometry reports SKIP; a reading, not a gate. Attachment identity/count changes record a named SKIP without changing render exit; supports --slot/--hide on both posers',
|
|
1069
1070
|
seam:
|
|
1070
1071
|
`also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
|
|
1071
1072
|
'per pair, the frames with a gap — background inside the hull of the band the two drawings share at the setup pose, carried to the frame by both — ' +
|
|
@@ -1756,7 +1757,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1756
1757
|
runtime: false,
|
|
1757
1758
|
spineFormat: false,
|
|
1758
1759
|
usage: [
|
|
1759
|
-
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--poser core|spine] [--out render/]',
|
|
1760
|
+
'rigc render --candidate <dir | skeleton.json> [--animation <name>] [--skin <name>] [--fps 12] [--max 256] [--geometry] [--folds] [--extremes] [--poser core|spine] [--out render/]',
|
|
1760
1761
|
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
1761
1762
|
'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
|
|
1762
1763
|
'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
|
|
@@ -1766,7 +1767,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1766
1767
|
'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
|
|
1767
1768
|
'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
|
|
1768
1769
|
],
|
|
1769
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'poser', 'out'],
|
|
1770
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'poser', 'out'],
|
|
1770
1771
|
overrides: {
|
|
1771
1772
|
'page-edges': {
|
|
1772
1773
|
meaning:
|
package/src/core/animation.ts
CHANGED
|
@@ -1033,7 +1033,7 @@ function posedBoneStep(doc: CompiledDocument, timelines: CoreAnimationTimelines,
|
|
|
1033
1033
|
const records = pathDeformed(doc, posedRecords(setupRecords, constraints, t), timelines, t, plant);
|
|
1034
1034
|
// A path constraint may read a slot bone the runtime has not yet brought up to date in this pass, as the previous pass left it (`./constraints_path.ts`, *Which slot bone*); `poseAnimations` holds that pass to the setup pose's reading.
|
|
1035
1035
|
const previous = setupRecords.some((r) => r.kind === 'path') ? applyConstraints(doc.bones, (plant.evaluate ?? worldTransforms)(doc.bones, active), active, plant.constraints ? plant.constraints(setupRecords) : setupRecords, null, undefined, undefined, undefined, solverRules(plant.solver)) : null;
|
|
1036
|
-
world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, undefined, solverRules(plant.solver));
|
|
1036
|
+
world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(records) : records, previous, sliders, undefined, plant.readLocals === undefined ? undefined : (state) => plant.readLocals?.(state.bones, sliders ?? []), solverRules(plant.solver));
|
|
1037
1037
|
if (stage !== undefined) stage.end('constraints');
|
|
1038
1038
|
}
|
|
1039
1039
|
for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
|
|
@@ -1066,7 +1066,7 @@ function steppedLocals(doc: CompiledDocument, bones: ModelBone[], plant: Timelin
|
|
|
1066
1066
|
stage.begin('constraints');
|
|
1067
1067
|
}
|
|
1068
1068
|
const stepped = stepPhysicsRecords(records(), physicsKeyed, t, active, step.ctx, step.before);
|
|
1069
|
-
const world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, undefined, solverRules(plant.solver));
|
|
1069
|
+
const world = applyConstraints(bones, initialWorld, active, plant.constraints ? plant.constraints(stepped) : stepped, null, sliders, step.ctx, plant.readLocals === undefined ? undefined : (state) => plant.readLocals?.(state.bones, sliders ?? []), solverRules(plant.solver));
|
|
1070
1070
|
if (stage !== undefined) stage.end('constraints');
|
|
1071
1071
|
for (const b of bones) if (world.get(b.name) === undefined) throw new CoreInputError(`the evaluator returned no transform for bone "${b.name}"`);
|
|
1072
1072
|
return { bones, world, active, round: plant.round ?? gridRound };
|
package/src/core/index.ts
CHANGED
|
@@ -1085,6 +1085,8 @@ export interface StageHooks {
|
|
|
1085
1085
|
* channel misread — and nothing else passes any.
|
|
1086
1086
|
*/
|
|
1087
1087
|
export interface CorePlant {
|
|
1088
|
+
/** Optional read of the solved local channels and applied slider mappings; never modifies the pose. */
|
|
1089
|
+
readLocals?: (bones: readonly ModelBone[], sliders: readonly SliderApplication[]) => void;
|
|
1088
1090
|
evaluate?: SetupEvaluator;
|
|
1089
1091
|
shown?: ShownResolver;
|
|
1090
1092
|
colour?: ColourReader;
|
|
@@ -1335,7 +1337,7 @@ export function poseSetup(doc: CompiledDocument, plant: CorePlant = {}, physics?
|
|
|
1335
1337
|
if (bonesWhy === null) {
|
|
1336
1338
|
const active = activeBones(doc);
|
|
1337
1339
|
const records = constraintRecords(doc);
|
|
1338
|
-
setupWorld = applyConstraints(doc.bones, evaluate(doc.bones, active), active, plant.constraints ? plant.constraints(records) : records, null, applied, physics, undefined, solverRules(plant.solver));
|
|
1340
|
+
setupWorld = applyConstraints(doc.bones, evaluate(doc.bones, active), active, plant.constraints ? plant.constraints(records) : records, null, applied, physics, plant.readLocals === undefined ? undefined : (state) => plant.readLocals?.(state.bones, applied), solverRules(plant.solver));
|
|
1339
1341
|
const world = setupWorld;
|
|
1340
1342
|
bones = doc.bones.map((b): CoreBoneRow => {
|
|
1341
1343
|
const t = world.get(b.name);
|
package/src/extremes.ts
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/** render --extremes: observations on one already-sampled set, never a quality gate. */
|
|
2
|
+
import { Plate } from '../tools/plate.ts';
|
|
3
|
+
import { hypot2 } from './numeric.ts';
|
|
4
|
+
import { attachmentSource } from './render_channels.ts';
|
|
5
|
+
import { renderFrame, TRANSPARENT_BACKGROUND, type Frame, type PoseOptions, type Viewport } from './render_shared.ts';
|
|
6
|
+
|
|
7
|
+
export class ExtremesError extends Error {}
|
|
8
|
+
export interface ExtremeFrame { index: number; time: number }
|
|
9
|
+
export interface ChannelExtreme {
|
|
10
|
+
path: string[];
|
|
11
|
+
min: ExtremeFrame & { value: number; reason: string };
|
|
12
|
+
max: ExtremeFrame & { value: number; reason: string };
|
|
13
|
+
}
|
|
14
|
+
export interface ExtremesReading {
|
|
15
|
+
spec: 'rigc-extremes/1';
|
|
16
|
+
animation: string | null;
|
|
17
|
+
fps: number;
|
|
18
|
+
grid: ExtremeFrame[];
|
|
19
|
+
method: {
|
|
20
|
+
channels: string;
|
|
21
|
+
geometry: string;
|
|
22
|
+
ties: string;
|
|
23
|
+
geometryScope: string;
|
|
24
|
+
panels: string;
|
|
25
|
+
diff: string;
|
|
26
|
+
};
|
|
27
|
+
channels: ChannelExtreme[];
|
|
28
|
+
channelStatus: 'READ' | 'PARTIAL' | 'SKIP';
|
|
29
|
+
channelRefusals: Array<{ path: string[] | null; reason: string }>;
|
|
30
|
+
status: 'READ' | 'SKIP';
|
|
31
|
+
reason: string;
|
|
32
|
+
geometryPair: { frames: [ExtremeFrame, ExtremeFrame]; maxVertexDistance: number; witness: { slot: string; attachment: string; vertex: number } } | null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const stamp = (f: Frame): ExtremeFrame => ({ index: f.index, time: f.time });
|
|
36
|
+
|
|
37
|
+
/** Channel validity is independent of geometry and of an image. A bad series cannot erase another series. */
|
|
38
|
+
function channelExtrema(frames: readonly Frame[]): Pick<ExtremesReading, 'channels' | 'channelStatus' | 'channelRefusals'> {
|
|
39
|
+
const channels = new Map<string, ChannelExtreme>();
|
|
40
|
+
const refusals = new Map<string, { path: string[] | null; reason: string }>();
|
|
41
|
+
const sources = new Map<string, object>();
|
|
42
|
+
const refuse = (path: string[] | null, why: string): void => {
|
|
43
|
+
const key = JSON.stringify(path);
|
|
44
|
+
channels.delete(key);
|
|
45
|
+
refusals.set(key, { path, reason: `EXTREMES_CHANNEL_REFUSED: ${why}` });
|
|
46
|
+
};
|
|
47
|
+
for (const f of frames) {
|
|
48
|
+
if (f.channelError !== undefined || f.channels === undefined) {
|
|
49
|
+
// A failed whole-frame read leaves every series' extrema unknown on this grid.
|
|
50
|
+
return { channels: [], channelStatus: 'SKIP', channelRefusals: [{ path: null, reason: `EXTREMES_CHANNEL_REFUSED: frame ${f.index}: ${f.channelError ?? 'local channels were not sampled'}` }] };
|
|
51
|
+
}
|
|
52
|
+
for (const sample of f.channels) {
|
|
53
|
+
const { path, value } = sample;
|
|
54
|
+
const key = JSON.stringify(path);
|
|
55
|
+
if (refusals.has(key)) continue;
|
|
56
|
+
if (!Number.isFinite(value)) { refuse(path, `frame ${f.index}, channel ${key}: found ${value}, required finite`); continue; }
|
|
57
|
+
if (path[0] === 'deform') {
|
|
58
|
+
const source = attachmentSource(sample);
|
|
59
|
+
if (source === undefined) { refuse(path, `frame ${f.index}, deform channel ${key}: original source identity unavailable`); continue; }
|
|
60
|
+
const before = sources.get(key);
|
|
61
|
+
if (before !== undefined && before !== source) {
|
|
62
|
+
refuse(path, `frame ${f.index}, deform channel ${key}: one slot/display-name/coordinate names different original attachment sources; separate extrema under that name are unavailable`);
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
sources.set(key, source);
|
|
66
|
+
}
|
|
67
|
+
const read = channels.get(key);
|
|
68
|
+
if (read === undefined) {
|
|
69
|
+
channels.set(key, { path,
|
|
70
|
+
min: { ...stamp(f), value, reason: 'minimum supplied local channel value on this grid; earliest tie; deform/slider samples only where applicable' },
|
|
71
|
+
max: { ...stamp(f), value, reason: 'maximum supplied local channel value on this grid; earliest tie; deform/slider samples only where applicable' },
|
|
72
|
+
});
|
|
73
|
+
} else {
|
|
74
|
+
if (value < read.min.value) read.min = { ...read.min, ...stamp(f), value };
|
|
75
|
+
if (value > read.max.value) read.max = { ...read.max, ...stamp(f), value };
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const byKey = <T>(rows: Map<string, T>): T[] => [...rows].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([, row]) => row);
|
|
80
|
+
return { channels: byKey(channels), channelStatus: refusals.size === 0 ? 'READ' : channels.size === 0 ? 'SKIP' : 'PARTIAL', channelRefusals: byKey(refusals) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Original attachment identity, independent of draw order and of a clipper's triangles. */
|
|
84
|
+
function geometryPair(frames: readonly Frame[], subset?: Pick<PoseOptions, 'slots' | 'hidden'>): ExtremesReading['geometryPair'] {
|
|
85
|
+
const selected = (slot: string): boolean => (subset?.slots === undefined || subset.slots.includes(slot)) && !subset?.hidden?.includes(slot);
|
|
86
|
+
const geometry = frames.map((f) => {
|
|
87
|
+
if (f.attachments === undefined) throw new ExtremesError(`frame ${f.index}: original attachment geometry was not sampled`);
|
|
88
|
+
const attachments = f.attachments.filter((a) => selected(a.slot));
|
|
89
|
+
for (const a of attachments) if (attachmentSource(a) === undefined) {
|
|
90
|
+
throw new ExtremesError(`frame ${f.index}, slot ${JSON.stringify(a.slot)}, attachment ${JSON.stringify(a.attachment)}: original source identity unavailable; required stable original-vertex correspondence`);
|
|
91
|
+
}
|
|
92
|
+
const byId = new Map(attachments.map((a) => [JSON.stringify([a.slot, a.attachment]), a]));
|
|
93
|
+
return [...byId].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
94
|
+
});
|
|
95
|
+
const first = geometry[0] ?? [];
|
|
96
|
+
for (let i = 0; i < geometry.length; i++) {
|
|
97
|
+
const row = geometry[i];
|
|
98
|
+
if (row.length !== first.length || row.some(([key, a], k) => key !== first[k]?.[0] || a.vertices.length !== first[k]?.[1].vertices.length || attachmentSource(a) !== attachmentSource(first[k][1]))) {
|
|
99
|
+
throw new ExtremesError(`frame ${frames[i].index}: attachment identity or vertex count changed (including actual source identity); cannot compare corresponding original vertices (expected ${first.map(([k, a]) => `${k}:${a.vertices.length / 2}`).join(', ')}, found ${row.map(([k, a]) => `${k}:${a.vertices.length / 2}`).join(', ')})`);
|
|
100
|
+
}
|
|
101
|
+
for (const [, a] of row) {
|
|
102
|
+
if (a.vertices.length % 2 || a.vertices.some((v) => !Number.isFinite(v))) throw new ExtremesError(`frame ${frames[i].index}, slot ${JSON.stringify(a.slot)}, attachment ${JSON.stringify(a.attachment)}: expected finite x/y vertex pairs`);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
let best = 0;
|
|
106
|
+
let pair: ExtremesReading['geometryPair'] = null;
|
|
107
|
+
for (let i = 0; i < frames.length; i++) for (let j = i + 1; j < frames.length; j++) {
|
|
108
|
+
for (let k = 0; k < first.length; k++) {
|
|
109
|
+
const a = geometry[i][k][1];
|
|
110
|
+
const b = geometry[j][k][1];
|
|
111
|
+
for (let v = 0; v < a.vertices.length; v += 2) {
|
|
112
|
+
const distance = hypot2(a.vertices[v] - b.vertices[v], a.vertices[v + 1] - b.vertices[v + 1]);
|
|
113
|
+
if (!Number.isFinite(distance)) throw new ExtremesError(`frames ${frames[i].index}/${frames[j].index}, slot ${JSON.stringify(a.slot)} vertex ${v / 2}: world distance is not finite`);
|
|
114
|
+
if (distance > best) {
|
|
115
|
+
best = distance;
|
|
116
|
+
pair = { frames: [stamp(frames[i]), stamp(frames[j])], maxVertexDistance: distance, witness: { slot: a.slot, attachment: a.attachment, vertex: v / 2 } };
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return pair;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function extremesOf(animation: string | null, fps: number, frames: readonly Frame[], subset?: Pick<PoseOptions, 'slots' | 'hidden'>): ExtremesReading {
|
|
125
|
+
const observedChannels = channelExtrema(frames);
|
|
126
|
+
let pair: ExtremesReading['geometryPair'] = null;
|
|
127
|
+
let geometryFailure: string | undefined;
|
|
128
|
+
try { pair = geometryPair(frames, subset); }
|
|
129
|
+
catch (err) { geometryFailure = `EXTREMES_OBSERVATION_REFUSED: geometry: ${err instanceof Error ? err.message : String(err)}`; }
|
|
130
|
+
return {
|
|
131
|
+
spec: 'rigc-extremes/1', animation, fps, grid: frames.map(stamp),
|
|
132
|
+
method: {
|
|
133
|
+
channels: 'Sampled posed local bone rotate, translate.x/y, scale.x/y, shear.x/y (including held components), deform offsets per coordinate, and actually applied slider mapped-time; channelStatus/channelRefusals describe independent failures. Deform series with one display-name path but different original sources are refused individually; independent of geometry pair. No key-time interpolation outside this grid.',
|
|
134
|
+
geometry: 'Exhaustive frame pairs i<j; distance=max over corresponding original attachment vertices of hypot2(dx,dy)=sqrt(dx*dx+dy*dy), dx/dy in world units (the shared deterministic length helper).',
|
|
135
|
+
ties: 'First in frame-grid order; geometry ties keep earliest (i,j), then attachment identity in UTF-16 code-unit order, then vertex index. Timestamps are copied from sampled frames.',
|
|
136
|
+
geometryScope: 'Original region/mesh vertices, before clipping, occlusion and tint alpha; filtered explicitly by --slot/--hide. Changing attachment source identity/count is refused, including different sources with equal display names and vertex counts.',
|
|
137
|
+
panels: 'setup | min | max | abs(max-min) | abs(min-setup) | abs(max-setup). min/max label earlier/later frame of the geometry pair, not joint channel minima/maxima. Native viewport size and scale, legend appended below.',
|
|
138
|
+
diff: 'L=round(max(abs(Ra*Aa/255-Rb*Ab/255),abs(Ga*Aa/255-Gb*Ab/255),abs(Ba*Aa/255-Bb*Ab/255),abs(Aa-Ab))); opaque (L,L,L,255) on black. No amplification or scaling.',
|
|
139
|
+
},
|
|
140
|
+
...observedChannels,
|
|
141
|
+
status: pair === null ? 'SKIP' : 'READ',
|
|
142
|
+
reason: geometryFailure ?? (pair === null ? 'no motion in corresponding selected original vertices on this frame grid' : 'farthest geometry pair on this frame grid; see method and witness'),
|
|
143
|
+
geometryPair: pair,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** A refused observation is a named SKIP, never a failed render. */
|
|
148
|
+
export function extremesRefusal(animation: string | null, fps: number, frames: readonly Frame[], reason: string, prior?: ExtremesReading): ExtremesReading {
|
|
149
|
+
const reading = prior ?? { ...extremesOf(animation, fps, []), ...channelExtrema(frames) };
|
|
150
|
+
return { ...reading, grid: frames.map(stamp), status: 'SKIP', reason: `EXTREMES_OBSERVATION_REFUSED: image: ${reason}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function extremesText(reading: ExtremesReading): string { return JSON.stringify(reading, null, 2) + '\n'; }
|
|
154
|
+
export function extremesLine(reading: ExtremesReading): string {
|
|
155
|
+
const name = JSON.stringify(reading.animation ?? 'setup-pose');
|
|
156
|
+
const p = reading.geometryPair;
|
|
157
|
+
const channels = `; channels ${reading.channelStatus} (${reading.channels.length} valid, ${reading.channelRefusals.length} refused)`;
|
|
158
|
+
return reading.status === 'SKIP' || p === null ? `EXTREMES ${name} SKIP: ${reading.reason}${channels}` : `EXTREMES ${name} geometry pair f${p.frames[0].index} @ ${p.frames[0].time}s / f${p.frames[1].index} @ ${p.frames[1].time}s; max vertex distance ${p.maxVertexDistance} world units${channels}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Alpha participates as a fourth premultiplied channel; RGB at alpha zero contributes zero. */
|
|
162
|
+
export function premultipliedDiff(a: Plate, b: Plate): Plate {
|
|
163
|
+
if (a.width !== b.width || a.height !== b.height) throw new ExtremesError('diff dimensions differ; required identical native pixel grids');
|
|
164
|
+
const out = new Plate(a.width, a.height);
|
|
165
|
+
for (let i = 0; i < out.data.length; i += 4) {
|
|
166
|
+
let d = Math.abs(a.data[i + 3] - b.data[i + 3]);
|
|
167
|
+
for (let c = 0; c < 3; c++) d = Math.max(d, Math.abs(a.data[i + c] * a.data[i + 3] / 255 - b.data[i + c] * b.data[i + 3] / 255));
|
|
168
|
+
out.data[i] = out.data[i + 1] = out.data[i + 2] = Math.round(d);
|
|
169
|
+
out.data[i + 3] = 255;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function extremesPlate(reading: ExtremesReading, setup: Frame, frames: readonly Frame[], pages: Map<string, Plate>, viewport: Viewport): Plate | null {
|
|
175
|
+
const pair = reading.geometryPair;
|
|
176
|
+
if (reading.status === 'SKIP' || pair === null) return null;
|
|
177
|
+
const find = (index: number): Frame => {
|
|
178
|
+
const f = frames.find((f) => f.index === index);
|
|
179
|
+
if (f === undefined) throw new ExtremesError(`chosen frame ${index} is absent`);
|
|
180
|
+
return f;
|
|
181
|
+
};
|
|
182
|
+
const base = renderFrame(setup, pages, viewport, TRANSPARENT_BACKGROUND);
|
|
183
|
+
const min = renderFrame(find(pair.frames[0].index), pages, viewport, TRANSPARENT_BACKGROUND);
|
|
184
|
+
const max = renderFrame(find(pair.frames[1].index), pages, viewport, TRANSPARENT_BACKGROUND);
|
|
185
|
+
const panels = [base, min, max, premultipliedDiff(max, min), premultipliedDiff(min, base), premultipliedDiff(max, base)];
|
|
186
|
+
const width = viewport.width * 6;
|
|
187
|
+
const legend = `1 setup | 2 min=f${pair.frames[0].index} (${pair.frames[0].time}s) | 3 max=f${pair.frames[1].index} (${pair.frames[1].time}s) | 4 abs(max-min) | 5 abs(min-setup) | 6 abs(max-setup). Geometry pair ordered by frame; channels listed separately in JSON. Diff: max premultiplied RGBA incl alpha, luminance on black, no amplification. Scale ${viewport.scale} px/world-unit.`;
|
|
188
|
+
const columns = Math.max(1, Math.floor(width / 6));
|
|
189
|
+
const rows = Math.ceil(legend.length / columns);
|
|
190
|
+
const out = new Plate(width, viewport.height + rows * 9 + 4);
|
|
191
|
+
// Legend black, all pixels opaque there; pictures retain their native alpha.
|
|
192
|
+
for (let i = viewport.height * width * 4 + 3; i < out.data.length; i += 4) out.data[i] = 255;
|
|
193
|
+
panels.forEach((panel, k) => {
|
|
194
|
+
for (let y = 0; y < viewport.height; y++) out.data.set(panel.data.subarray(y * panel.width * 4, (y + 1) * panel.width * 4), (y * width + k * panel.width) * 4);
|
|
195
|
+
});
|
|
196
|
+
for (let row = 0; row < rows; row++) out.text(legend.slice(row * columns, (row + 1) * columns), 0, viewport.height + 2 + row * 9, 1, [255, 255, 255, 255]);
|
|
197
|
+
return out;
|
|
198
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { localChannels, deformChannels, identifyAttachment } from './render_channels.ts';
|
|
1
2
|
/**
|
|
2
3
|
* The rasteriser — one code path for reference frames and for candidates.
|
|
3
4
|
*
|
|
@@ -118,6 +119,7 @@ import {
|
|
|
118
119
|
Skeleton,
|
|
119
120
|
SkeletonClipping,
|
|
120
121
|
SkeletonJson,
|
|
122
|
+
Slider,
|
|
121
123
|
TextureAtlas,
|
|
122
124
|
TextureAtlasRegion,
|
|
123
125
|
SkeletonData,
|
|
@@ -548,8 +550,21 @@ function spinePosed(skeleton: Skeleton): Posed {
|
|
|
548
550
|
return {
|
|
549
551
|
pieces: (draw) => drawPieces(skeleton, draw),
|
|
550
552
|
bones: () => boneSnapshots(skeleton),
|
|
551
|
-
attachments: () => attachmentsOf(skeleton),
|
|
553
|
+
attachments: (identity) => attachmentsOf(skeleton, identity),
|
|
552
554
|
physics: () => steppedPhysicsOf(skeleton),
|
|
555
|
+
channels: () => {
|
|
556
|
+
const values = localChannels(skeleton.bones.map((b) => {
|
|
557
|
+
const p = b.appliedPose;
|
|
558
|
+
return { name: b.data.name, rotation: p.rotation, x: p.x, y: p.y, scaleX: p.scaleX, scaleY: p.scaleY, shearX: p.shearX, shearY: p.shearY };
|
|
559
|
+
}), skeleton.constraints.filter((c): c is Slider => c instanceof Slider && c.active && c.appliedPose.mix !== 0 && (c.bone === null || c.bone.active)).map((c) => ({ name: c.data.name, time: c.appliedPose.time })));
|
|
560
|
+
for (const slot of skeleton.slots) {
|
|
561
|
+
const a = slot.appliedPose.attachment;
|
|
562
|
+
if (!(a instanceof MeshAttachment)) continue;
|
|
563
|
+
const setup = a.bones === null ? Array.from(a.vertices) : new Array<number>(a.vertices.length / 3 * 2).fill(0);
|
|
564
|
+
values.push(...deformChannels(slot.data.name, a.name, slot.appliedPose.deform, setup, a));
|
|
565
|
+
}
|
|
566
|
+
return values;
|
|
567
|
+
},
|
|
553
568
|
};
|
|
554
569
|
}
|
|
555
570
|
|
|
@@ -803,17 +818,18 @@ function worldVerticesOf(skeleton: Skeleton, slot: Slot, attachment: MeshAttachm
|
|
|
803
818
|
* Every slot's region or mesh attachment, whole, in the posed draw order — see
|
|
804
819
|
* `Frame.attachments`. No subset and no clip: those are `piecesOf`'s business.
|
|
805
820
|
*/
|
|
806
|
-
function attachmentsOf(skeleton: Skeleton): AttachmentPose[] {
|
|
821
|
+
function attachmentsOf(skeleton: Skeleton, identity = false): AttachmentPose[] {
|
|
807
822
|
const out: AttachmentPose[] = [];
|
|
808
823
|
for (const slot of skeleton.drawOrder.appliedPose) {
|
|
809
824
|
const attachment = slot.appliedPose.attachment;
|
|
810
825
|
if (!(attachment instanceof MeshAttachment) && !(attachment instanceof RegionAttachment)) continue;
|
|
811
|
-
|
|
826
|
+
const row = {
|
|
812
827
|
slot: slot.data.name,
|
|
813
828
|
attachment: attachment.name,
|
|
814
829
|
vertices: worldVerticesOf(skeleton, slot, attachment),
|
|
815
830
|
color: tintOf(slot, attachment),
|
|
816
|
-
}
|
|
831
|
+
};
|
|
832
|
+
out.push(identity ? identifyAttachment(row, attachment) : row);
|
|
817
833
|
}
|
|
818
834
|
return out;
|
|
819
835
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/** Local scalar readings shared by the two posers; no stepping or inferred world decomposition. */
|
|
2
|
+
import type { ModelBone } from './model.ts';
|
|
3
|
+
import type { ChannelValue } from './render_shared.ts';
|
|
4
|
+
|
|
5
|
+
const FIELDS = {
|
|
6
|
+
rotation: ['rotate', 0], x: ['translate.x', 0], y: ['translate.y', 0],
|
|
7
|
+
scaleX: ['scale.x', 1], scaleY: ['scale.y', 1], shearX: ['shear.x', 0], shearY: ['shear.y', 0],
|
|
8
|
+
} as const;
|
|
9
|
+
|
|
10
|
+
export function localChannels(bones: readonly ModelBone[], sliders: readonly { name: string; time: number }[]): ChannelValue[] {
|
|
11
|
+
const out: ChannelValue[] = [];
|
|
12
|
+
for (const bone of bones) {
|
|
13
|
+
for (const field of Object.keys(FIELDS) as Array<keyof typeof FIELDS>) {
|
|
14
|
+
const [name, setup] = FIELDS[field];
|
|
15
|
+
out.push({ path: ['bone', bone.name, name], value: bone[field] ?? setup });
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const slider of sliders) out.push({ path: ['slider', slider.name, 'mapped-time'], value: slider.time });
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Offsets from setup, x/y per influence for weighted meshes, x/y per vertex otherwise. */
|
|
23
|
+
export function deformChannels(slot: string, attachment: string, deform: readonly number[], setup: readonly number[], source?: object): ChannelValue[] {
|
|
24
|
+
return setup.map((v, i) => {
|
|
25
|
+
const read = { path: ['deform', slot, attachment, String(i)], value: deform.length === 0 ? 0 : deform[i] - v };
|
|
26
|
+
return source === undefined ? read : identifyAttachment(read, source);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The display name can be overridden by an export. Keep actual source identity out of serialized geometry.
|
|
31
|
+
const attachmentSources = new WeakMap<object, object>();
|
|
32
|
+
export function identifyAttachment<T extends object>(pose: T, source: object): T {
|
|
33
|
+
attachmentSources.set(pose, source);
|
|
34
|
+
return pose;
|
|
35
|
+
}
|
|
36
|
+
export function attachmentSource(pose: object): object | undefined { return attachmentSources.get(pose); }
|
package/src/render_core.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { localChannels, deformChannels, identifyAttachment } from './render_channels.ts';
|
|
2
|
+
import type { ChannelValue } from './render_shared.ts';
|
|
1
3
|
/**
|
|
2
4
|
* The render's second poser (issue #968, step 3d of issue #380): the posing
|
|
3
5
|
* seam of `src/render.ts` (`Poser` / `Posed`) implemented over rigc's own
|
|
@@ -462,7 +464,7 @@ function clipCover(pose: RawPose, active: ReadonlySet<string>): Map<string, Clip
|
|
|
462
464
|
}
|
|
463
465
|
|
|
464
466
|
/** One posed moment of the core, read through the seam's `Posed`. */
|
|
465
|
-
function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper, stepped: readonly string[]): Posed {
|
|
467
|
+
function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper, stepped: readonly string[], channels?: ChannelValue[]): Posed {
|
|
466
468
|
const slotRows = new Map<string, CoreSlotRow>(pose.slots.map((row) => [row[0], row]));
|
|
467
469
|
let regions: Map<string, DrawnRegion> | null = null;
|
|
468
470
|
const regionsOf = (): Map<string, DrawnRegion> => {
|
|
@@ -504,8 +506,25 @@ function corePosed(input: CoreInput, pose: RawPose, through: ShapeClipper, stepp
|
|
|
504
506
|
scaleY: b.scaleY,
|
|
505
507
|
}) : inactiveBoneSnapshot(b.name));
|
|
506
508
|
},
|
|
507
|
-
attachments: (): AttachmentPose[] =>
|
|
508
|
-
pose.drawn.map((d) =>
|
|
509
|
+
attachments: (identity = false): AttachmentPose[] =>
|
|
510
|
+
pose.drawn.map((d) => {
|
|
511
|
+
const row = { slot: d.slot, attachment: d.attachment, vertices: [...d.vertices], color: tintOf(d) };
|
|
512
|
+
if (!identity) return row;
|
|
513
|
+
const source = pose.shown.find((s) => s.slot === d.slot)?.geometry;
|
|
514
|
+
if (source === undefined) throw new CoreInputError(`slot "${d.slot}" has no original attachment identity`);
|
|
515
|
+
return identifyAttachment(row, source);
|
|
516
|
+
}),
|
|
517
|
+
...(channels === undefined ? {} : { channels: (): ChannelValue[] => {
|
|
518
|
+
const out = [...channels];
|
|
519
|
+
for (const shown of pose.shown) {
|
|
520
|
+
const g = shown.geometry;
|
|
521
|
+
const v = g.kind === 'mesh' ? g.vertices : g.kind === 'linkedmesh' ? sourceOfDoc(input.doc)(g.skin, g.slot, g.source) : null;
|
|
522
|
+
if (v === null || typeof v === 'string') continue;
|
|
523
|
+
const setup = v.weighted ? new Array<number>(v.bindings.reduce((n, b) => n + b.length * 2, 0)).fill(0) : v.xy;
|
|
524
|
+
out.push(...deformChannels(shown.slot, shown.name, shown.deform ?? [], setup, g));
|
|
525
|
+
}
|
|
526
|
+
return out;
|
|
527
|
+
} }),
|
|
509
528
|
physics: (): string[] => [...stepped],
|
|
510
529
|
};
|
|
511
530
|
}
|
|
@@ -756,11 +775,16 @@ export function corePoser(
|
|
|
756
775
|
bones: doc.bones.map((b) => ({ name: b.name, parent: b.parent ?? null })),
|
|
757
776
|
slots: doc.slots.map((s) => ({ name: s.name, bone: s.bone })),
|
|
758
777
|
subset: (opts, skin) => subsetOver(roster, opts, skin),
|
|
759
|
-
setup: (skin) => {
|
|
778
|
+
setup: (skin, channels) => {
|
|
760
779
|
const input = inputOf(skin);
|
|
761
|
-
|
|
780
|
+
let values: ChannelValue[] | undefined;
|
|
781
|
+
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
782
|
+
const pose = poseRawSetup(input.doc, { through, ...observe });
|
|
783
|
+
return corePosed(input, pose, through, [], values);
|
|
762
784
|
},
|
|
763
|
-
animation: (name, skin, fps, count, visit, environment) => {
|
|
785
|
+
animation: (name, skin, fps, count, visit, environment, channels) => {
|
|
786
|
+
let values: ChannelValue[] | undefined;
|
|
787
|
+
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
764
788
|
const input = inputOf(skin);
|
|
765
789
|
const step = 1 / fps;
|
|
766
790
|
// Each pose is drawn as it is posed and released before the next (issue #1180), so the walk holds one pose and not
|
|
@@ -778,12 +802,12 @@ export function corePoser(
|
|
|
778
802
|
if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
|
|
779
803
|
return moved;
|
|
780
804
|
};
|
|
781
|
-
walk(input.doc, name, new Array<number>(count).fill(step), { through, physicsStep }, (pose, i) => {
|
|
805
|
+
walk(input.doc, name, new Array<number>(count).fill(step), { through, physicsStep, ...observe }, (pose, i) => {
|
|
782
806
|
const stepped = [...steppedNow].sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
783
807
|
steppedNow.clear();
|
|
784
808
|
if (drawFailed.length > 0) return;
|
|
785
809
|
try {
|
|
786
|
-
visit(i, corePosed(input, pose, through, stepped));
|
|
810
|
+
visit(i, corePosed(input, pose, through, stepped, values));
|
|
787
811
|
} catch (thrown) {
|
|
788
812
|
drawFailed.push(thrown);
|
|
789
813
|
}
|
package/src/render_shared.ts
CHANGED
|
@@ -415,6 +415,8 @@ export interface UvWindow {
|
|
|
415
415
|
|
|
416
416
|
/** Options for `piecesOf` and the samplers that call it. */
|
|
417
417
|
export interface PoseOptions {
|
|
418
|
+
/** Also read local bone/deform channels and mapped slider times, and retain original attachment source identity outside JSON, for --extremes. */
|
|
419
|
+
channels?: boolean;
|
|
418
420
|
/** Also record each piece's original-art UVs — see `PieceTexture`. */
|
|
419
421
|
texture?: boolean;
|
|
420
422
|
/**
|
|
@@ -595,7 +597,13 @@ export interface ClipSource {
|
|
|
595
597
|
/** One drawable in a posed frame. */
|
|
596
598
|
export type Piece = Quad | Mesh;
|
|
597
599
|
|
|
600
|
+
export interface ChannelValue { path: string[]; value: number }
|
|
601
|
+
|
|
598
602
|
export interface Frame {
|
|
603
|
+
/** Sampled local channels, only when requested. */
|
|
604
|
+
channels?: ChannelValue[];
|
|
605
|
+
/** A failed optional observation does not fail the draw. */
|
|
606
|
+
channelError?: string;
|
|
599
607
|
/** Index within the sampled sequence — the number in `f0000.png`. */
|
|
600
608
|
index: number;
|
|
601
609
|
time: number;
|
|
@@ -1107,12 +1115,14 @@ export interface DrawOptions {
|
|
|
1107
1115
|
* what it needs before the next frame is posed.
|
|
1108
1116
|
*/
|
|
1109
1117
|
export interface Posed {
|
|
1118
|
+
/** Optional channel read, on the very pose drawn; no extra stepping. */
|
|
1119
|
+
channels?(): ChannelValue[];
|
|
1110
1120
|
/** The drawables in draw order — see `piecesOf` for the walk and the clip. */
|
|
1111
1121
|
pieces(draw: DrawOptions): Piece[];
|
|
1112
1122
|
/** Every bone's world transform, in the skeleton's declaration order. */
|
|
1113
1123
|
bones(): BoneSnapshot[];
|
|
1114
|
-
/** Every slot's region or mesh attachment, whole, in the
|
|
1115
|
-
attachments(): AttachmentPose[];
|
|
1124
|
+
/** Every slot's region or mesh attachment, whole, in draw order; identity=true retains the original source in an out-of-JSON registry for --extremes. */
|
|
1125
|
+
attachments(identity?: boolean): AttachmentPose[];
|
|
1116
1126
|
/**
|
|
1117
1127
|
* The physics constraints the step that posed this moment stepped, by name in
|
|
1118
1128
|
* code-unit order (issue #1467). A constraint is stepped when, after a step
|
|
@@ -1143,14 +1153,14 @@ export interface Poser {
|
|
|
1143
1153
|
/** `slotSubsetOf` against this skeleton posed under `skin` — refused by `SlotSubsetError`. */
|
|
1144
1154
|
subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
|
|
1145
1155
|
/** The setup pose under `skin` (absent: no skin set at all). */
|
|
1146
|
-
setup(skin: string | undefined): Posed;
|
|
1156
|
+
setup(skin: string | undefined, channels?: boolean): Posed;
|
|
1147
1157
|
/**
|
|
1148
1158
|
* Animation `name` under `skin`, not looping: `visit(i, posed)` for every
|
|
1149
1159
|
* `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
|
|
1150
1160
|
* `environment(i)`, when given, is the change to the world the physics
|
|
1151
1161
|
* answers set before the step that poses frame `i` (issue #1416).
|
|
1152
1162
|
*/
|
|
1153
|
-
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined): void;
|
|
1163
|
+
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean): void;
|
|
1154
1164
|
/** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
|
|
1155
1165
|
rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
|
|
1156
1166
|
}
|
|
@@ -1188,12 +1198,21 @@ function drawResolver(poser: Poser, opts: PoseOptions | undefined): () => DrawOp
|
|
|
1188
1198
|
|
|
1189
1199
|
/** One frame off one posed moment, with what `opts` asked to record beside the pieces. */
|
|
1190
1200
|
function frameOf(index: number, time: number, posed: Posed, draw: DrawOptions, opts: PoseOptions | undefined): Frame {
|
|
1201
|
+
let channels: Pick<Frame, 'channels' | 'channelError'> = {};
|
|
1202
|
+
if (opts?.channels) {
|
|
1203
|
+
try {
|
|
1204
|
+
if (posed.channels === undefined) throw new Error('poser does not expose local channels');
|
|
1205
|
+
channels = { channels: posed.channels() };
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) { channels = { channelError: `frame ${index}: local channel read failed: ${err instanceof Error ? err.message : String(err)}` }; }
|
|
1208
|
+
}
|
|
1191
1209
|
return {
|
|
1192
1210
|
index,
|
|
1193
1211
|
time,
|
|
1194
1212
|
pieces: posed.pieces(draw),
|
|
1213
|
+
...channels,
|
|
1195
1214
|
...(opts?.bones ? { bones: posed.bones() } : {}),
|
|
1196
|
-
...(opts?.geometry ? { attachments: posed.attachments() } : {}),
|
|
1215
|
+
...(opts?.geometry ? { attachments: opts.channels ? posed.attachments(true) : posed.attachments() } : {}),
|
|
1197
1216
|
...(opts?.physics ? { physics: posed.physics() } : {}),
|
|
1198
1217
|
};
|
|
1199
1218
|
}
|
|
@@ -1220,7 +1239,8 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
|
|
|
1220
1239
|
frames.push(frameOf(i, i * step, posed, draw(), opts));
|
|
1221
1240
|
};
|
|
1222
1241
|
// Without an environment the call is the one it always was, so a poser that takes none is asked nothing new.
|
|
1223
|
-
if (opts?.
|
|
1242
|
+
if (opts?.channels) poser.animation(name, opts.skin, fps, count, visit, opts.environment === undefined ? undefined : environmentAt(opts.environment, fps), true);
|
|
1243
|
+
else if (opts?.environment === undefined) poser.animation(name, opts?.skin, fps, count, visit);
|
|
1224
1244
|
else poser.animation(name, opts.skin, fps, count, visit, environmentAt(opts.environment, fps));
|
|
1225
1245
|
return frames;
|
|
1226
1246
|
}
|
|
@@ -1235,7 +1255,7 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
|
|
|
1235
1255
|
*/
|
|
1236
1256
|
export function sampleSetupPose(source: PoseSource, opts?: PoseOptions): Frame[] {
|
|
1237
1257
|
const poser = poserOf(source);
|
|
1238
|
-
const posed = poser.setup(opts?.skin);
|
|
1258
|
+
const posed = opts?.channels ? poser.setup(opts.skin, true) : poser.setup(opts?.skin);
|
|
1239
1259
|
return [frameOf(0, 0, posed, drawResolver(poser, opts)(), opts)];
|
|
1240
1260
|
}
|
|
1241
1261
|
|