spine-rigc 3.2.1 → 3.4.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/docs/AUTHORING.md +77 -1
- package/package.json +1 -1
- package/src/boundary.ts +129 -0
- package/src/check.ts +3 -0
- package/src/cli/core_commands.ts +28 -9
- package/src/cli/shared.ts +8 -3
- package/src/render.ts +6 -6
- package/src/render_core.ts +4 -3
- package/src/render_shared.ts +14 -6
- package/src/seam.ts +79 -6
- package/src/sheet.ts +6 -0
package/docs/AUTHORING.md
CHANGED
|
@@ -130,6 +130,7 @@ commands take it and what its default is.
|
|
|
130
130
|
| `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)) <a id="cmd-split"></a> |
|
|
131
131
|
| `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 <a id="cmd-explain"></a> |
|
|
132
132
|
| `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; ghost counts only visible contributions after draw order; `exposureA`/`exposureB` report each named slot’s whole-drawing background exposure even without a band ([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. `--extremes-only` writes the same `extremes.json` and readable `extremes.png` bytes while skipping ordinary frame rasterization/encoding, `contact.png` and `frames.json`; it implies `--extremes` and takes no other diagnostic or sheet output flags. 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. `--preroll <n>` walks each set `n` whole loops on a looping track before the loop it records, so a looping idle is drawn with its physics already warm, as a player's looping track carries it ([AUTHORING §8.13](../docs/AUTHORING.md#813-a-loop-with-its-physics-warm--render---preroll)). `--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 <a id="cmd-render"></a> |
|
|
133
|
+
| `render … --physics off --boundary-from <dir>` | Suppress every physics constraint on both posers and read visible boundary displacement from a second render on the same viewport and frame grid. `frames.json` records off, `check` refuses it as a reference, and `boundary.json` carries per-frame distances or named SKIPs. See §8.14. |
|
|
133
134
|
| `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` <a id="cmd-preview"></a> |
|
|
134
135
|
| `vote --candidate a --candidate b` | one `.html` that asks a human which; `vote --record <file>` checks the answer into `votes.jsonl` <a id="cmd-vote"></a> |
|
|
135
136
|
| `pose --images <dir> --frame <png>` | reads part placements **out of** a picture <a id="cmd-pose"></a> |
|
|
@@ -377,11 +378,12 @@ What the flags mean:
|
|
|
377
378
|
| `--max` | `render` and `inspect`: the longest side of the frame in pixels (default `256`, finite and at least `16`). On `render`, refused with `--viewport-from`, which already fixes the pixel size — **§8.6**, **§13** |
|
|
378
379
|
| `--show`, `--dim`, `--at`, `--zoom` | `inspect` only — what it draws, whether the art is faded, which times of `--animation` it poses and the bone, slot or folds it frames to; §13 says each, with the refusals |
|
|
379
380
|
| `--geometry` | `render` only: also write a `geometry.json` into each frame directory — per frame, every bone's world transform and every slot's region or mesh vertices in world units after skinning, plus each attachment's rest geometry and topology, on the frames' own grid and viewport. Every other file is byte for byte what the render writes without it. Refused with `--slot`/`--hide`, because the geometry is the whole pose whatever is drawn — **§8.2** |
|
|
380
|
-
| `--extremes-only` | `render` only: imply `--extremes`, but write only `extremes.json` and readable `extremes.png` with the same bytes. Pose the same grid; rasterize only the setup/min/max pictures used by the plate. No ordinary frame PNGs, contact sheet or `frames.json`. Takes no `--sheet`, `--sheet-format`, `--page-edges`, `--geometry`, `--folds`, `--pass-through`, `--seam`, `--overlaps` or `--angle-range`; drop the named flag or use `--extremes` instead — **§8.11** |
|
|
381
|
+
| `--extremes-only` | `render` only: imply `--extremes`, but write only `extremes.json` and readable `extremes.png` with the same bytes. Pose the same grid; rasterize only the setup/min/max pictures used by the plate. No ordinary frame PNGs, contact sheet or `frames.json`. Takes no `--sheet`, `--sheet-format`, `--page-edges`, `--geometry`, `--folds`, `--pass-through`, `--seam`, `--seam-cells`, `--overlaps` or `--angle-range`; drop the named flag or use `--extremes` instead — **§8.11** |
|
|
381
382
|
| `--parallax` | `render` only: a named slider dial, through the core poser. Write `parallax.json` and a readable `parallax.png` into `--out` on a fixed 9-value grid. This is a separate dial render mode, with `--skin`, `--max`, `--candidate`, `--atlas` and `--poser core`; it takes no animation, fps, subset, other diagnostic, sheet, environment or viewport flag. A missing dial is refused by name; unavailable fits report SKIP, never a pass — **§8.12** |
|
|
382
383
|
| `--extremes` | `render` only: add `extremes.json` and an `EXTREMES` line per set; a moving set with comparable geometry and readable images also gets a native six-panel `extremes.png`: setup, geometry-pair min/max and three premultiplied RGBA diffs with a legend. Per-channel extrema are independent of that geometry pair; valid channels survive geometry/image SKIPs, and channel refusals are named separately. Still or uncomparable geometry is a named SKIP, with no invented pair or PNG; observation failures do not change a successful render's exit status. Supports `--slot`/`--hide` on both posers, on the whole rig's viewport. Without the flag, no extremes observations are added — **§8.11** |
|
|
383
384
|
| `--folds` | `render` only: also read which mesh triangles **fold** — turn over against the setup pose — and write a `folds.json` into each frame directory: per mesh, the frames with a folded triangle, the worst frame's count and which triangles, by index, with their world box and setup-pose area. One printed line per mesh that folds; a triangle degenerate or inverted at the setup pose is said once and never counted. A reading, not a gate. Every other file is byte for byte what the render writes without it, `geometry.json` included. Refused with `--slot`/`--hide`, for `--geometry`'s reason — **§8.3** |
|
|
384
385
|
| `--pass-through` | `render` only: `<cloth>:<leg>[,<cloth>:<leg>…]` — also read where each cloth slot **passes through** its leg slot, and write a `pass-through.json` into each frame directory: the frames where the cloth draws over the leg and its drawn side lies inside the leg's drawn outline by 2 steps or more, each with the depth and the world box. One printed line per pair, and one per frame that passes through. The first name must draw over the second for a frame to be read — the declared order, never both. A reading, not a gate. Every other file is byte for byte what the render writes without it. An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name, and so is `--slot`/`--hide` beside it — **§8.5** |
|
|
386
|
+
| `--seam-cells` | `render` only, with `--seam`: add exact `cells` lists to `gapOpen`/`gapCovered` hits (including `worst`), with setup-pose top and underlying slot names at the same world cell. Without `--seam` it is refused before output. No effect on the counts — **§8.8** |
|
|
385
387
|
| `--seam` | `render` only: `<slot>:<slot>[,<slot>:<slot>…]` — also read, for each pair, where the two drawings **stop meeting**, and write a `seam.json` into each frame directory: per pair, the frames with a **gap** (background inside the joint, where the two covered together at the setup pose) and the frames with a **ghost** (the visible overlap band of one showing where, from its partner's point of view, nothing was drawn), plus `exposureA`/`exposureB` for each named slot’s positive-alpha drawing on otherwise empty skeleton background, each with its cell count, area and world box, and as runs of frames with their times. One printed line per pair; a pair whose drawings share no cell at the setup pose is a **SKIP**. A reading, not a gate: the exit status never moves. Every other file is byte for byte what the render writes without it. An unknown slot, a slot with itself, a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name, and so is `--slot`/`--hide` beside it — **§8.8** |
|
|
386
388
|
| `--overlaps` | `render` only: also read **which slot each frame drew over which**, and write an `overlaps.json` into each frame directory: per frame, every pair of slots where the slot drawn later covers pixels the earlier one drew (drawn at 128 of 255 or more, on the frames' own pixels), with the pixel count and the pixel box. One `OVERLAP` line per pair that covers anything — its frames as runs, how many of the set, and the most pixels and the frame of them — and one line counting the pairs that are layering the motion does not move. 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 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 author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Every other file is byte for byte what the render writes without it, and `frames.json` gains each set's `overlaps` record. Refused beside `--slot`/`--hide` — **§8.9** |
|
|
387
389
|
| `--angle-range` | `render` only: `<bone>:<relative-to>[,<bone>:<relative-to>…]` — also read, for each pair, the range of the first bone's world direction against the second's over each set's frames, and print one line per pair per set: the lowest and the highest angle in degrees, the span, and the frame each was first reached on. A reading, not a gate: nothing is written, and every file is the bytes the render writes without it. An unknown bone (with the bones there are), a bone against itself, a malformed list and a pair named twice are refused by name, and so is a bone with no direction on a frame — **§8.7** |
|
|
@@ -9395,6 +9397,33 @@ block carries the definitions above in words.
|
|
|
9395
9397
|
| hit `area` | number | world units² | `px × cell²` |
|
|
9396
9398
|
| hit `box` | `[minX, minY, maxX, maxY]` | world units, y up | the counted cells' whole squares, in Spine world |
|
|
9397
9399
|
|
|
9400
|
+
**Exact cells, opt-in.** Add `--seam-cells` beside `--seam` to retain the
|
|
9401
|
+
cells counted by the existing gap classifier. Only `gapOpen` and `gapCovered`
|
|
9402
|
+
hits gain `cells`; `gap`, `ghost` and exposure hits keep their old shape. Each
|
|
9403
|
+
entry is `{ x, y, setupTop, setupUnder }`: `x,y` are the cell **centre** in world
|
|
9404
|
+
units, y up; the hit's `cell` gives the square's side. Lists are in descending
|
|
9405
|
+
`y`, then ascending `x` order (top row first, left to right), with no duplicates.
|
|
9406
|
+
Every nonempty hit has `cells.length === px`; absent hits still mean zero and
|
|
9407
|
+
SKIP pairs remain unmeasured. `worst` carries the same list as its frame hit.
|
|
9408
|
+
|
|
9409
|
+
`setupTop` is the topmost slot whose setup drawing emits positive rendered alpha
|
|
9410
|
+
at that **same world cell**, and `setupUnder` is the next distinct covering slot
|
|
9411
|
+
beneath it in setup draw order. Each is a slot name or `null` if absent. All
|
|
9412
|
+
slots participate, including third slots, faint alpha, texture holes and clipped
|
|
9413
|
+
fragments; multiple fragments of one slot count as one layer. These are setup
|
|
9414
|
+
layers, not the current frame's cover, not spatial neighbors, and not a cell
|
|
9415
|
+
carried back by the seam's inverse attachment maps. A currently open cell can
|
|
9416
|
+
therefore name setup art; a cell beyond all setup art has two null names.
|
|
9417
|
+
|
|
9418
|
+
This is an additive opt-in extension of **`rigc-seam/1`**: readers ignoring
|
|
9419
|
+
unknown hit keys retain every existing number and meaning. Exact-object readers
|
|
9420
|
+
must opt into the new shape. With the flag absent no setup attribution is sampled
|
|
9421
|
+
or rasterized and the sidecar bytes remain unchanged. With it present, setup is
|
|
9422
|
+
sampled lazily once per recipe/skin and its rendered-alpha layers are cached per
|
|
9423
|
+
distinct seam cell side across pairs and animation sets; only listed cells query
|
|
9424
|
+
the cache. Determinism controls compare two complete flagged renders, not just
|
|
9425
|
+
list lengths.
|
|
9426
|
+
|
|
9398
9427
|
A downstream warning is a projection of this file and needs nothing re-measured:
|
|
9399
9428
|
one span of one kind is `{ pair: "<a>|<b>", frames: span.frames, times:
|
|
9400
9429
|
span.times, maxPx: span.maxPx, kind }`, and one pair over one animation is
|
|
@@ -9950,6 +9979,53 @@ still refused above 0, would reach the same looping walk; counted in steps rathe
|
|
|
9950
9979
|
than loops, it would move every compared frame off its `FrameRef` time unless the
|
|
9951
9980
|
steps were whole loops.
|
|
9952
9981
|
|
|
9982
|
+
### 8.14 Physics-off renders and boundary comparisons
|
|
9983
|
+
|
|
9984
|
+
`rigc render --candidate <dir> --physics off --out <off>` suppresses every physics
|
|
9985
|
+
constraint on both posers, after the animation timelines apply. Every other
|
|
9986
|
+
constraint and the animation clock keep their ordinary walk. `--physics on` is the
|
|
9987
|
+
default. Any other value is refused by name. An omitted mode retains the existing
|
|
9988
|
+
render bytes. Off records `physicsMode: "off"` in `frames.json`, geometry and sheet
|
|
9989
|
+
metadata, and adds a `physics=off` variant to sheet frame keys. A reference
|
|
9990
|
+
without the field means on. `check` refuses an off reference because its candidate
|
|
9991
|
+
is posed with physics on.
|
|
9992
|
+
|
|
9993
|
+
To compare two whole-figure renders, first render the reference, then render the
|
|
9994
|
+
second with `--viewport-from <reference-dir> --boundary-from <reference-dir>`.
|
|
9995
|
+
For example, render the public gallery physics recipe normally, then render it
|
|
9996
|
+
with those two flags and `--physics off`. Use the same animation selection, skin,
|
|
9997
|
+
fps and pre-roll on both runs. The shared viewport fixes the world box, scale and
|
|
9998
|
+
pixel dimensions. Check the viewport's recorded `pinned.outside` when interpreting
|
|
9999
|
+
an outline that reaches the canvas edge.
|
|
10000
|
+
|
|
10001
|
+
`boundary.json` (`rigc-boundary-difference/1`) records each matching frame's maximum
|
|
10002
|
+
symmetric nearest-boundary distance in **L-infinity pixels**, and the maximum over
|
|
10003
|
+
the frames read. A boundary pixel is visible foreground with at least one
|
|
10004
|
+
four-neighbour background pixel, or a foreground pixel on the canvas edge. Holes
|
|
10005
|
+
contribute boundaries too. Foreground means the rendered RGBA differs from the
|
|
10006
|
+
recorded background exactly. Background-coloured art has no observable coverage
|
|
10007
|
+
in this reading, and anything outside the viewport is absent. This is a pixel
|
|
10008
|
+
reading with no registration, threshold or pass verdict.
|
|
10009
|
+
|
|
10010
|
+
An identical on/on pair reads zero. An on/off pair measures the visible outline
|
|
10011
|
+
change on the chosen grid. Empty boundaries report `SKIP`, never zero. A frame
|
|
10012
|
+
schedule, viewport, background, skin, set roster or file mismatch also reports
|
|
10013
|
+
`SKIP` with a reason. Missing or unreadable frames remain unavailable. Mixed
|
|
10014
|
+
readable and empty frames report `PARTIAL` with the per-frame reasons. Both sides
|
|
10015
|
+
must record the same slot selection. `scope` records that selection explicitly. A backdrop is part of the visible outline when it is drawn, so a
|
|
10016
|
+
full-canvas opaque backdrop can make the distance zero even while the character
|
|
10017
|
+
moves. For `gallery/look`, use `--hide plate` on both renders to read the character
|
|
10018
|
+
without its backdrop. Every other drawn slot still contributes to that outline.
|
|
10019
|
+
`--extremes-only` takes no `--boundary-from`, and the reference directory must differ
|
|
10020
|
+
from `--out`. Reusing an output directory clears its previous `boundary.json`
|
|
10021
|
+
before writing the new render, including an `--extremes-only` render.
|
|
10022
|
+
|
|
10023
|
+
`--extremes` chooses a pair within one animation and reads corresponding authored
|
|
10024
|
+
vertices and local channels. Subtracting two extreme readings does not compare
|
|
10025
|
+
the same frames' visible outlines. `diff` compares skeleton data, and `check`
|
|
10026
|
+
reposes and registers a candidate against reference pictures. Neither reads two
|
|
10027
|
+
already-rendered whole-figure boundaries on an unchanged grid.
|
|
10028
|
+
|
|
9953
10029
|
## 9. Checking against the frames — `rigc check`
|
|
9954
10030
|
|
|
9955
10031
|
```bash
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "spine-rigc",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.4.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/boundary.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** Visible boundary displacement on the recorded pixel grid, without registration or thresholds. */
|
|
2
|
+
import { readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
|
|
5
|
+
import { FRAMES_SPEC, type FramesSidecar } from './render_shared.ts';
|
|
6
|
+
|
|
7
|
+
type DistanceReading = { status: 'READ'; maxPixels: number } | { status: 'SKIP'; reason: string };
|
|
8
|
+
|
|
9
|
+
/** Background-coloured pixels carry no observable foreground coverage. */
|
|
10
|
+
export function visibleBoundary(plate: Plate, background: RGBA): number[] {
|
|
11
|
+
const { width, height, data } = plate;
|
|
12
|
+
const foreground = new Uint8Array(width * height);
|
|
13
|
+
for (let pixel = 0; pixel < foreground.length; pixel++) {
|
|
14
|
+
foreground[pixel] = background.some((value, channel) => data[pixel * 4 + channel] !== value) ? 1 : 0;
|
|
15
|
+
}
|
|
16
|
+
const boundary: number[] = [];
|
|
17
|
+
for (let y = 0; y < height; y++) {
|
|
18
|
+
for (let x = 0; x < width; x++) {
|
|
19
|
+
const pixel = y * width + x;
|
|
20
|
+
if (foreground[pixel] && (
|
|
21
|
+
x === 0 || y === 0 || x === width - 1 || y === height - 1 ||
|
|
22
|
+
!foreground[pixel - 1] || !foreground[pixel + 1] ||
|
|
23
|
+
!foreground[pixel - width] || !foreground[pixel + width]
|
|
24
|
+
)) boundary.push(pixel);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return boundary;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Exact eight-neighbour nearest-boundary distance in L-infinity pixels. Linear in canvas area. */
|
|
31
|
+
function distances(width: number, height: number, boundary: readonly number[]): Int32Array {
|
|
32
|
+
const distance = new Int32Array(width * height).fill(-1);
|
|
33
|
+
const queue = new Int32Array(distance.length);
|
|
34
|
+
let end = 0;
|
|
35
|
+
for (const pixel of boundary) {
|
|
36
|
+
distance[pixel] = 0;
|
|
37
|
+
queue[end++] = pixel;
|
|
38
|
+
}
|
|
39
|
+
for (let head = 0; head < end; head++) {
|
|
40
|
+
const pixel = queue[head];
|
|
41
|
+
const x = pixel % width;
|
|
42
|
+
const y = Math.floor(pixel / width);
|
|
43
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
44
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
45
|
+
const nextX = x + dx, nextY = y + dy;
|
|
46
|
+
if (nextX < 0 || nextY < 0 || nextX >= width || nextY >= height) continue;
|
|
47
|
+
const next = nextY * width + nextX;
|
|
48
|
+
if (distance[next] !== -1) continue;
|
|
49
|
+
distance[next] = distance[pixel] + 1;
|
|
50
|
+
queue[end++] = next;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return distance;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function boundaryDistance(a: Plate, b: Plate, background: RGBA): DistanceReading {
|
|
58
|
+
if (a.width !== b.width || a.height !== b.height) return { status: 'SKIP', reason: 'pixel dimensions differ' };
|
|
59
|
+
const left = visibleBoundary(a, background), right = visibleBoundary(b, background);
|
|
60
|
+
if (left.length === 0 || right.length === 0) {
|
|
61
|
+
return { status: 'SKIP', reason: 'one or both frames have no visible foreground boundary' };
|
|
62
|
+
}
|
|
63
|
+
const toLeft = distances(a.width, a.height, left), toRight = distances(a.width, a.height, right);
|
|
64
|
+
let maxPixels = 0;
|
|
65
|
+
for (const pixel of left) maxPixels = Math.max(maxPixels, toRight[pixel]);
|
|
66
|
+
for (const pixel of right) maxPixels = Math.max(maxPixels, toLeft[pixel]);
|
|
67
|
+
return { status: 'READ', maxPixels };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const SCHEDULE_FIELDS = ['animation', 'fps', 'sampled', 'written', 'stride', 'duration'] as const;
|
|
71
|
+
|
|
72
|
+
/** A failed observation remains a named SKIP and never becomes a zero distance. */
|
|
73
|
+
export function boundaryComparison(reference: string, current: string, sidecar: FramesSidecar) {
|
|
74
|
+
const spec = 'rigc-boundary-difference/1';
|
|
75
|
+
const skip = (reason: string) => ({ spec, status: 'SKIP' as const, reason });
|
|
76
|
+
try {
|
|
77
|
+
if (realpathSync(reference) === realpathSync(current)) return skip('reference and output directories are the same');
|
|
78
|
+
const before = JSON.parse(readFileSync(join(reference, 'frames.json'), 'utf8')) as FramesSidecar;
|
|
79
|
+
if (before.spec !== FRAMES_SPEC || !Array.isArray(before.sets)) return skip('reference requires a render frames.json');
|
|
80
|
+
const coordinates = Object.keys(sidecar.viewport) as Array<keyof typeof sidecar.viewport>;
|
|
81
|
+
if (!before.viewport || coordinates.some((key) => !Number.isFinite(before.viewport[key]) || before.viewport[key] !== sidecar.viewport[key])) {
|
|
82
|
+
return skip('world viewport or pixel grid differs, use --viewport-from');
|
|
83
|
+
}
|
|
84
|
+
if (JSON.stringify(before.background) !== JSON.stringify(sidecar.background)) return skip('recorded backgrounds differ');
|
|
85
|
+
if (before.skin !== sidecar.skin || JSON.stringify(before.slots) !== JSON.stringify(sidecar.slots) || JSON.stringify(before.hidden) !== JSON.stringify(sidecar.hidden)) {
|
|
86
|
+
return skip(`skin or drawn slot selection differs: reference ${JSON.stringify({ skin: before.skin ?? null, slots: before.slots ?? null, hidden: before.hidden ?? null })}, current ${JSON.stringify({ skin: sidecar.skin ?? null, slots: sidecar.slots ?? null, hidden: sidecar.hidden ?? null })}. Required equal recorded selections`);
|
|
87
|
+
}
|
|
88
|
+
const frames: Array<{ set: string; file: string } & DistanceReading> = [];
|
|
89
|
+
for (const set of sidecar.sets) {
|
|
90
|
+
const match = before.sets.find((other) => other.dir === set.dir);
|
|
91
|
+
if (!match || SCHEDULE_FIELDS.some((key) => match[key] !== set[key])) {
|
|
92
|
+
return skip(`set ${JSON.stringify(set.dir)} has a different frame schedule`);
|
|
93
|
+
}
|
|
94
|
+
if (!set.dir || set.dir === '..' || set.dir === '.' || set.dir.includes('/') || set.dir.includes('\\')) {
|
|
95
|
+
return skip('set directory must be one relative directory name');
|
|
96
|
+
}
|
|
97
|
+
const names = (root: string): string[] => readdirSync(join(root, set.dir)).filter((name) => /^f\d+\.png$/.test(name)).sort();
|
|
98
|
+
const left = names(reference), right = names(current);
|
|
99
|
+
if (left.length === 0 || left.length !== set.written || JSON.stringify(left) !== JSON.stringify(right)) {
|
|
100
|
+
return skip(`set ${JSON.stringify(set.dir)} has missing or different frame files`);
|
|
101
|
+
}
|
|
102
|
+
for (const file of left) {
|
|
103
|
+
const a = readPlate(join(reference, set.dir, file)), b = readPlate(join(current, set.dir, file));
|
|
104
|
+
if ([a, b].some((plate) => plate.width !== sidecar.viewport.pixelWidth || plate.height !== sidecar.viewport.pixelHeight)) {
|
|
105
|
+
return skip(`set ${JSON.stringify(set.dir)} frame ${file} pixel dimensions disagree with frames.json`);
|
|
106
|
+
}
|
|
107
|
+
frames.push({ set: set.dir, file, ...boundaryDistance(a, b, sidecar.background) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
if (before.sets.length !== sidecar.sets.length || frames.length === 0) return skip('set roster differs or no frames were compared');
|
|
111
|
+
const readings = frames.filter((frame) => frame.status === 'READ');
|
|
112
|
+
let maxPixels = 0;
|
|
113
|
+
for (const frame of readings) if (frame.status === 'READ') maxPixels = Math.max(maxPixels, frame.maxPixels);
|
|
114
|
+
return {
|
|
115
|
+
spec,
|
|
116
|
+
status: readings.length === frames.length ? 'READ' : readings.length ? 'PARTIAL' : 'SKIP',
|
|
117
|
+
metric: 'symmetric Hausdorff L-infinity pixels',
|
|
118
|
+
coverage: 'visible pixels differing from the recorded background. Four-neighbour boundary including holes, clipped to viewport',
|
|
119
|
+
referencePhysics: before.physicsMode ?? 'on',
|
|
120
|
+
physics: sidecar.physicsMode ?? 'on',
|
|
121
|
+
scope: { slots: sidecar.slots ?? null, hidden: sidecar.hidden ?? null },
|
|
122
|
+
viewport: sidecar.viewport,
|
|
123
|
+
maxPixels: readings.length ? maxPixels : null,
|
|
124
|
+
frames,
|
|
125
|
+
};
|
|
126
|
+
} catch (error) {
|
|
127
|
+
return skip(`boundary unavailable: ${error instanceof Error ? error.message : String(error)}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
package/src/check.ts
CHANGED
|
@@ -1298,6 +1298,9 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
1298
1298
|
'difference between two worlds. Render the reference frames without --environment.',
|
|
1299
1299
|
);
|
|
1300
1300
|
}
|
|
1301
|
+
if (located.sidecar?.physicsMode === 'off') {
|
|
1302
|
+
throw new CheckError('frames.json records physicsMode off, while check poses the candidate with physics on. Render the reference with --physics on.');
|
|
1303
|
+
}
|
|
1301
1304
|
// A frame set drawn after a pre-roll (`render --preroll`, issue #1684) shows the physics warm; check poses the
|
|
1302
1305
|
// candidate from rest, so the score would be a number about the difference between a warm loop and a cold one.
|
|
1303
1306
|
const referencePreroll = located.sidecar?.preroll;
|
package/src/cli/core_commands.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { boundaryComparison } from '../boundary.ts';
|
|
1
2
|
import { sampleParallax, parallaxPlate } from '../parallax_render.ts';
|
|
2
3
|
import { textureScaleReading, textureScaleLines, TEXTURE_SCALE_FPS } from '../texture_scale.ts';
|
|
3
4
|
import { extremesOf, extremesText, extremesPlate, extremesLine, extremesRefusal } from '../extremes.ts';
|
|
@@ -44,7 +45,7 @@ import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passT
|
|
|
44
45
|
import type { Plate } from '../../tools/plate.ts';
|
|
45
46
|
import { OVERLAPS_FILE, OverlapError, overlapLine, overlapPlate, overlapsFileOf, overlapsOf, overlapsPlateFile, overlapsText, plateParticipants, staticLine } from '../overlaps.ts';
|
|
46
47
|
import { buildSheetSet, SHEET_MANIFEST_FILE, type SheetSetPack, SheetUsageError, sheetRigNameOf, sheetSidecarRecord, sheetSourceOf } from '../sheet.ts';
|
|
47
|
-
import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText } from '../seam.ts';
|
|
48
|
+
import { readSeamPairs, SEAM_FILE, seamFileOf, seamLine, SeamError, type SeamPair, seamsOf, seamText, setupCellReader } from '../seam.ts';
|
|
48
49
|
import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
|
|
49
50
|
import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
|
|
50
51
|
import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, frameSetDir, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, prerollStart, prerollWarmPoints, 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';
|
|
@@ -723,6 +724,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
723
724
|
// The pre-roll (issue #1684), read and refused as early, and refused beside a schedule: under a pre-roll a schedule's
|
|
724
725
|
// `t` could be read on the recording's clock or on the walk's, the two give different physics, and nothing settles it.
|
|
725
726
|
const preroll = readPrerollFlag(flags);
|
|
727
|
+
if (flags.physics !== undefined && flags.physics !== 'on' && flags.physics !== 'off') throw new UsageError(`--physics ${JSON.stringify(flags.physics)}: required on or off`);
|
|
728
|
+
const physicsMode = flags.physics === 'off' ? 'off' as const : undefined;
|
|
726
729
|
if (preroll !== undefined && environment !== undefined) {
|
|
727
730
|
throw new UsageError(
|
|
728
731
|
`--preroll takes no --environment: a schedule's t could be read from the first frame recorded or from the first loop walked, ` +
|
|
@@ -734,7 +737,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
734
737
|
const given = readGivenViewport(flags);
|
|
735
738
|
const extremesOnly = flags['extremes-only'] !== undefined;
|
|
736
739
|
if (extremesOnly) {
|
|
737
|
-
for (const flag of ['sheet', 'sheet-format', 'page-edges', 'geometry', 'folds', 'pass-through', 'seam', 'overlaps', 'angle-range']) {
|
|
740
|
+
for (const flag of ['sheet', 'sheet-format', 'page-edges', 'geometry', 'folds', 'pass-through', 'seam', 'seam-cells', 'overlaps', 'angle-range', 'boundary-from']) {
|
|
738
741
|
if (flags[flag] !== undefined) {
|
|
739
742
|
throw new UsageError(`--extremes-only takes no --${flag}: it writes only extremes.json and, when readable, extremes.png. Drop --${flag}, or use --extremes for ordinary frames and additional outputs`);
|
|
740
743
|
}
|
|
@@ -753,6 +756,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
753
756
|
const sheetFormat = wantsSheet ? readSheetFormat(flags) : undefined;
|
|
754
757
|
const sheetPageEdges = wantsSheet ? readPageEdges(flags) : undefined;
|
|
755
758
|
const outRoot = resolve(flags.out ?? 'render');
|
|
759
|
+
if (flags['boundary-from'] !== undefined && realpathAhead(flags['boundary-from']) === realpathAhead(outRoot)) throw new UsageError('--boundary-from must name an earlier render directory different from --out');
|
|
756
760
|
const geometry = flags.geometry !== undefined;
|
|
757
761
|
const extremes = extremesOnly || flags.extremes !== undefined;
|
|
758
762
|
// The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
|
|
@@ -823,6 +827,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
823
827
|
// The seam reading (issue #1456) is its own flag for --folds' reason, and refused beside a subset for --pass-through's:
|
|
824
828
|
// it reads the two slots as the whole rig draws them.
|
|
825
829
|
const seam = flags.seam;
|
|
830
|
+
const seamCells = flags['seam-cells'] !== undefined;
|
|
831
|
+
if (seamCells && seam === undefined) throw new UsageError('--seam-cells needs --seam <slot>:<slot>[,…]; no pair was supplied');
|
|
826
832
|
if (seam !== undefined) {
|
|
827
833
|
for (const flag of ['slot', 'hide'] as const) {
|
|
828
834
|
if (flags[flag] !== undefined) {
|
|
@@ -850,7 +856,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
850
856
|
posersAsked(flags),
|
|
851
857
|
);
|
|
852
858
|
if (flags.parallax !== undefined) {
|
|
853
|
-
for (const flag of ['animation', 'fps', 'slot', 'hide', 'geometry', 'folds', 'extremes', 'extremes-only', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'viewport', 'viewport-from']) {
|
|
859
|
+
for (const flag of ['animation', 'fps', 'slot', 'hide', 'geometry', 'folds', 'extremes', 'extremes-only', 'pass-through', 'seam', 'seam-cells', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'physics', 'boundary-from', 'viewport', 'viewport-from']) {
|
|
854
860
|
if (flags[flag] !== undefined) throw new UsageError(`--parallax takes no --${flag}: it samples one named dial on its own 9-value grid; drop --${flag} or run ordinary render separately`);
|
|
855
861
|
}
|
|
856
862
|
if (flags.poser !== undefined && flags.poser !== 'core') throw new UsageError('--parallax requires --poser core: it reads only a rigc compiled model');
|
|
@@ -898,13 +904,14 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
898
904
|
// what the frames under it draw.
|
|
899
905
|
// The pre-roll rides on it for the environment's reason (issue #1684): it changes the pose, so the framing reads it.
|
|
900
906
|
const pose =
|
|
901
|
-
skin === undefined && subset === undefined && environment === undefined && preroll === undefined
|
|
907
|
+
skin === undefined && subset === undefined && environment === undefined && preroll === undefined && physicsMode === undefined
|
|
902
908
|
? undefined
|
|
903
909
|
: {
|
|
904
910
|
...(skin === undefined ? {} : { skin }),
|
|
905
911
|
...subsetFields(subset),
|
|
906
912
|
...(environment === undefined ? {} : { environment: environment.steps }),
|
|
907
913
|
...(preroll === undefined ? {} : { preroll }),
|
|
914
|
+
...(physicsMode === undefined ? {} : { physicsMode }),
|
|
908
915
|
};
|
|
909
916
|
if (skin !== undefined) console.log(` .. skin ${skin}`);
|
|
910
917
|
if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
|
|
@@ -1019,7 +1026,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1019
1026
|
if (geometry) {
|
|
1020
1027
|
for (const [name, frames] of sampled) {
|
|
1021
1028
|
const animation = name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name;
|
|
1022
|
-
geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps, preroll)));
|
|
1029
|
+
geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps, preroll, physicsMode)));
|
|
1023
1030
|
}
|
|
1024
1031
|
}
|
|
1025
1032
|
// The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
|
|
@@ -1065,6 +1072,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1065
1072
|
// whose slot draws on no frame of any set drawn is refused, as --pass-through refuses it, rather than read as a SKIP.
|
|
1066
1073
|
const seamReadings = new Map<string, ReturnType<typeof seamFileOf>>();
|
|
1067
1074
|
if (seamPairs !== undefined) {
|
|
1075
|
+
const cells = seamCells ? setupCellReader(() => sampleSetupPose(poser, { skin })[0].pieces, pages) : undefined;
|
|
1068
1076
|
const draws = new Set<string>();
|
|
1069
1077
|
for (const frames of sampled.values()) for (const frame of frames) for (const piece of frame.pieces) draws.add(piece.slot);
|
|
1070
1078
|
for (const pair of seamPairs) {
|
|
@@ -1082,7 +1090,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1082
1090
|
const shown = frames.map((frame) => frame.attachments ?? []);
|
|
1083
1091
|
const read = frames.map((frame) => ({ index: frame.index, time: frame.time, pieces: frame.pieces, attachments: frame.attachments ?? [] }));
|
|
1084
1092
|
try {
|
|
1085
|
-
seamReadings.set(name, seamFileOf(animation, fps, skin, seamsOf(seamPairs, poser.rest(skin, shown), read, pages)));
|
|
1093
|
+
seamReadings.set(name, seamFileOf(animation, fps, skin, seamsOf(seamPairs, poser.rest(skin, shown), read, pages, {}, { cells })));
|
|
1086
1094
|
} catch (err) {
|
|
1087
1095
|
if (err instanceof SeamError) throw new UsageError(`--seam, ${JSON.stringify(name)}: ${err.message}`);
|
|
1088
1096
|
throw err;
|
|
@@ -1151,6 +1159,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1151
1159
|
subset,
|
|
1152
1160
|
environment: environment?.steps,
|
|
1153
1161
|
preroll,
|
|
1162
|
+
physicsMode,
|
|
1154
1163
|
pinned,
|
|
1155
1164
|
}),
|
|
1156
1165
|
);
|
|
@@ -1194,6 +1203,8 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1194
1203
|
}
|
|
1195
1204
|
|
|
1196
1205
|
mkdirSync(outRoot, { recursive: true });
|
|
1206
|
+
// A prior observation cannot describe the new frames, including an observation-only render.
|
|
1207
|
+
rmSync(join(outRoot, 'boundary.json'), { force: true });
|
|
1197
1208
|
if (extremesOnly) {
|
|
1198
1209
|
// Reuse the same posed grid and plates; selecting extremes needs no ordinary pixels.
|
|
1199
1210
|
// Clear only the selected sets, just as the ordinary render does. No frames.json
|
|
@@ -1289,7 +1300,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1289
1300
|
: ` + ${sheetPack.pages.map((p) => `${p.image} + ${p.table}`).join(' + ')} + ${SHEET_MANIFEST_FILE} (${sheetPack.pages.length} page(s), ${sheetPack.pageEdges})`;
|
|
1290
1301
|
const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${sheetNote}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}${overlapNote}`;
|
|
1291
1302
|
console.log(` .. ${name.padEnd(16)} ${frames.length} frame(s), ${how}${extras} -> ${dir}`);
|
|
1292
|
-
if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length)}`);
|
|
1303
|
+
if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length, physicsMode)}`);
|
|
1293
1304
|
for (const mesh of foldReading?.meshes ?? []) {
|
|
1294
1305
|
const key = `${mesh.slot}\u0000${mesh.attachment}`;
|
|
1295
1306
|
const fault = faultsSaid.has(key) ? null : setupFaultLine(mesh);
|
|
@@ -1330,6 +1341,7 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1330
1341
|
...(environment === undefined ? {} : { environment: environment.steps }),
|
|
1331
1342
|
// Written only when a pre-roll was asked for (issue #1684), for the same reason.
|
|
1332
1343
|
...(preroll === undefined ? {} : { preroll }),
|
|
1344
|
+
...(physicsMode === undefined ? {} : { physicsMode }),
|
|
1333
1345
|
// Written only when the box was given (issue #1414): a fitted render says nothing and stays the bytes it was.
|
|
1334
1346
|
...(given === undefined ? {} : { pinned: pinnedRecord(given.by, outside) }),
|
|
1335
1347
|
background: BACKGROUND,
|
|
@@ -1340,6 +1352,13 @@ export function cmdRender(flags: Record<string, string>): void {
|
|
|
1340
1352
|
};
|
|
1341
1353
|
writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
|
|
1342
1354
|
console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
|
|
1355
|
+
if (flags['boundary-from'] !== undefined) {
|
|
1356
|
+
const reading = boundaryComparison(resolve(flags['boundary-from']), outRoot, sidecar);
|
|
1357
|
+
writeFileSync(join(outRoot, 'boundary.json'), `${JSON.stringify(reading, null, 2)}\n`);
|
|
1358
|
+
const detail = 'reason' in reading ? reading.reason : `max ${String(reading.maxPixels)} L-infinity px over ${reading.frames.length} frame(s)`;
|
|
1359
|
+
console.log(` .. boundary ${reading.status}: ${detail}`);
|
|
1360
|
+
console.log(`rigc: wrote ${join(outRoot, 'boundary.json')}`);
|
|
1361
|
+
}
|
|
1343
1362
|
}
|
|
1344
1363
|
|
|
1345
1364
|
/**
|
|
@@ -1371,11 +1390,11 @@ export function physicsSteppedOf(frames: readonly Frame[], fps: number): { stepp
|
|
|
1371
1390
|
* `render`'s `physics:` line for one set (issue #1467): how many constraints were stepped at the set's rate, and — when
|
|
1372
1391
|
* fewer than the rig declares — the ones that took no step, by name, with why a constraint takes none.
|
|
1373
1392
|
*/
|
|
1374
|
-
export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number): string {
|
|
1393
|
+
export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number, physicsMode?: 'off'): string {
|
|
1375
1394
|
const head = `physics: ${stepped.length} constraint(s) stepped at ${fps} fps`;
|
|
1376
1395
|
const idle = declared.filter((name) => !stepped.includes(name));
|
|
1377
1396
|
if (idle.length === 0) return head;
|
|
1378
|
-
const why = frames === 1 ? 'a single pose takes no step' : 'inactive, or a mix of 0, on every frame';
|
|
1397
|
+
const why = physicsMode === 'off' ? '--physics off suppresses every physics constraint' : frames === 1 ? 'a single pose takes no step' : 'inactive, or a mix of 0, on every frame';
|
|
1379
1398
|
return `${head} — ${idle.length} of the ${declared.length} declared took none: ${idle.map((n) => JSON.stringify(n)).join(', ')} (${why})`;
|
|
1380
1399
|
}
|
|
1381
1400
|
|
package/src/cli/shared.ts
CHANGED
|
@@ -151,7 +151,7 @@ export { PACKAGE_ROOT, readPackageMeta, readVersion };
|
|
|
151
151
|
* needs a value` (issue #328). `CLI10`/`CLI11` in `selftest.ts` now hold the two
|
|
152
152
|
* halves together by reading `--help` rather than by naming a flag.
|
|
153
153
|
*/
|
|
154
|
-
const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'folds', 'extremes', 'extremes-only', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
154
|
+
const BOOLEAN_FLAGS = new Set(['all', 'all-frames', 'all-bones', 'help', 'copy-images', 'again', 'pack', 'copy', 'geometry', 'seam-cells', 'folds', 'extremes', 'extremes-only', 'overlaps', 'sheet', 'accept-skeleton-differences', 'keep-pages', 'dim']);
|
|
155
155
|
|
|
156
156
|
/**
|
|
157
157
|
* The flags a command is allowed to spell more than once.
|
|
@@ -1072,8 +1072,9 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1072
1072
|
'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 ' +
|
|
1073
1073
|
"author's, and the draw-order key in the motion spec is the remedy. A reading, not a gate. Not with --slot/--hide",
|
|
1074
1074
|
parallax: 'read one named slider dial through the core poser on 9 evenly spaced values: parallax.json plus setup/extreme/diff/affine-residual heat-map parallax.png; geometry only, no assertion or pass threshold. A separate dial render mode; takes no animation, fps, other diagnostics, sheet, subset, environment, preroll or viewport flags',
|
|
1075
|
-
'extremes-only': 'write only extremes.json and readable extremes.png per set, with the same posed grid and bytes as --extremes; no ordinary frame PNGs, contact.png or frames.json. Implies --extremes; takes no --sheet, --sheet-format, --page-edges, --geometry, --folds, --pass-through, --seam, --overlaps or --angle-range',
|
|
1075
|
+
'extremes-only': 'write only extremes.json and readable extremes.png per set, with the same posed grid and bytes as --extremes; no ordinary frame PNGs, contact.png or frames.json. Implies --extremes; takes no --sheet, --sheet-format, --page-edges, --geometry, --folds, --pass-through, --seam, --seam-cells, --overlaps or --angle-range',
|
|
1076
1076
|
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',
|
|
1077
|
+
'seam-cells': 'with --seam, include exact world-grid cells on gapOpen/gapCovered hits, with setupTop/setupUnder slots; absent by default',
|
|
1077
1078
|
seam:
|
|
1078
1079
|
`also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
|
|
1079
1080
|
'per pair, the frames with a gap — background inside the carried overlap hull and a local sweep of the same setup band cell through both drawings — ' +
|
|
@@ -1108,6 +1109,8 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1108
1109
|
'runtime\'s, wind [1, 0] and gravity [0, 1]. frames.json and geometry.json record it, and the framing reads it. A ' +
|
|
1109
1110
|
'malformed file, a half no physics constraint answers, two steps on one frame and a step after every set\'s last frame ' +
|
|
1110
1111
|
'are refused by name. An inspection of what the rig offers, not a scene',
|
|
1112
|
+
physics: 'pose with physics on (default) or off. Off suppresses every physics constraint after timeline application on both posers. frames.json records physicsMode off and check refuses that reference',
|
|
1113
|
+
'boundary-from': 'read visible whole-figure pixel-boundary displacement against an earlier render on the identical viewport and frame grid. Writes boundary.json, no threshold or pass verdict',
|
|
1111
1114
|
preroll:
|
|
1112
1115
|
'walk each set this many whole loops on a looping track before the loop recorded, drawn by no frame, the physics carried ' +
|
|
1113
1116
|
'across every wrap as a player\'s looping track carries it — so a spring that converges is warm on the first frame. Frame i is the ' +
|
|
@@ -1307,6 +1310,8 @@ const FLAG_VALUES: Record<string, string> = {
|
|
|
1307
1310
|
'texture-from': '<path>',
|
|
1308
1311
|
poser: 'core|spine',
|
|
1309
1312
|
environment: '<schedule.json>',
|
|
1313
|
+
physics: '<on|off>',
|
|
1314
|
+
'boundary-from': '<dir>',
|
|
1310
1315
|
preroll: '<n>',
|
|
1311
1316
|
'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
|
|
1312
1317
|
seam: '<slot>:<slot>[,<slot>:<slot>…]',
|
|
@@ -1785,7 +1790,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1785
1790
|
'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
|
|
1786
1791
|
'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
|
|
1787
1792
|
],
|
|
1788
|
-
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'extremes-only', 'parallax', 'pass-through', 'seam', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'poser', 'out'],
|
|
1793
|
+
flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'extremes', 'extremes-only', 'parallax', 'pass-through', 'seam', 'seam-cells', 'overlaps', 'angle-range', 'sheet', 'sheet-format', 'page-edges', 'environment', 'preroll', 'physics', 'boundary-from', 'poser', 'out'],
|
|
1789
1794
|
overrides: {
|
|
1790
1795
|
'page-edges': {
|
|
1791
1796
|
meaning:
|
package/src/render.ts
CHANGED
|
@@ -502,8 +502,8 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
502
502
|
bones: data.bones.map((bone) => ({ name: bone.name, parent: bone.parent?.name ?? null })),
|
|
503
503
|
slots: data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
504
504
|
subset: (opts, skin) => slotSubsetOf(data, opts, skin),
|
|
505
|
-
setup: (skin) => spinePosed(setupPosed(data, skin)),
|
|
506
|
-
animation: (name, skin, fps, count, visit, environment, _channels, times, loop, clockSteps) => {
|
|
505
|
+
setup: (skin, _channels, physicsMode) => spinePosed(setupPosed(data, skin, physicsMode)),
|
|
506
|
+
animation: (name, skin, fps, count, visit, environment, _channels, times, loop, clockSteps, physicsMode) => {
|
|
507
507
|
const skeleton = skeletonUnderSkin(data, skin);
|
|
508
508
|
const state = new AnimationState(new AnimationStateData(data));
|
|
509
509
|
// Not looping: the last frame sits at the animation's duration, and a looping
|
|
@@ -524,12 +524,12 @@ export function spinePoser(data: SkeletonData): Poser {
|
|
|
524
524
|
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
525
525
|
// `clockSteps` (issue #1687, `render --preroll`): what the step moves the physics clock by, where that is not its own length.
|
|
526
526
|
skeleton.update(times === undefined ? clock : physicsClockStep(clockSteps?.[i - 1] ?? delta));
|
|
527
|
-
skeleton.updateWorldTransform(Physics.update);
|
|
527
|
+
skeleton.updateWorldTransform(physicsMode === 'off' ? Physics.none : Physics.update);
|
|
528
528
|
} else {
|
|
529
529
|
state.apply(skeleton);
|
|
530
530
|
setSkeletonEnvironment(skeleton, environment?.(i));
|
|
531
531
|
skeleton.update(0);
|
|
532
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
532
|
+
skeleton.updateWorldTransform(physicsMode === 'off' ? Physics.none : Physics.reset);
|
|
533
533
|
}
|
|
534
534
|
visit(i, posed);
|
|
535
535
|
}
|
|
@@ -592,11 +592,11 @@ function steppedPhysicsOf(skeleton: Skeleton): string[] {
|
|
|
592
592
|
* `sampleSetupPose` and the geometry export's `rest` table both pose from, so
|
|
593
593
|
* the two cannot come to describe different rest poses.
|
|
594
594
|
*/
|
|
595
|
-
function setupPosed(data: SkeletonData, skin: string | undefined): Skeleton {
|
|
595
|
+
function setupPosed(data: SkeletonData, skin: string | undefined, physicsMode?: 'off'): Skeleton {
|
|
596
596
|
const skeleton = skeletonUnderSkin(data, skin);
|
|
597
597
|
skeleton.setupPose();
|
|
598
598
|
skeleton.update(0);
|
|
599
|
-
skeleton.updateWorldTransform(Physics.reset);
|
|
599
|
+
skeleton.updateWorldTransform(physicsMode === 'off' ? Physics.none : Physics.reset);
|
|
600
600
|
return skeleton;
|
|
601
601
|
}
|
|
602
602
|
|
package/src/render_core.ts
CHANGED
|
@@ -804,14 +804,14 @@ export function corePoser(
|
|
|
804
804
|
const result = poseRawDial(input.doc, override, name, { through });
|
|
805
805
|
return { posed: corePosed(input, result.pose, through, []), read: result.read, applied: result.applied };
|
|
806
806
|
},
|
|
807
|
-
setup: (skin, channels) => {
|
|
807
|
+
setup: (skin, channels, physicsMode) => {
|
|
808
808
|
const input = inputOf(skin);
|
|
809
809
|
let values: ChannelValue[] | undefined;
|
|
810
810
|
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
811
|
-
const pose = poseRawSetup(input.doc, { through, ...observe });
|
|
811
|
+
const pose = poseRawSetup(input.doc, { through, ...observe, ...(physicsMode === 'off' ? { physicsStep: () => false } : {}) });
|
|
812
812
|
return corePosed(input, pose, through, [], values);
|
|
813
813
|
},
|
|
814
|
-
animation: (name, skin, fps, count, visit, environment, channels, times, loop, clockSteps) => {
|
|
814
|
+
animation: (name, skin, fps, count, visit, environment, channels, times, loop, clockSteps, physicsMode) => {
|
|
815
815
|
let values: ChannelValue[] | undefined;
|
|
816
816
|
const observe = channels ? { readLocals: (bones: readonly import('./model.ts').ModelBone[], sliders: readonly import('./core/constraints_slider.ts').SliderApplication[]) => { values = localChannels(bones, sliders); } } : {};
|
|
817
817
|
const input = inputOf(skin);
|
|
@@ -827,6 +827,7 @@ export function corePoser(
|
|
|
827
827
|
const steppedNow = new Set<string>();
|
|
828
828
|
const inner = plants.physicsStep ?? stepPhysics;
|
|
829
829
|
const physicsStep: PhysicsStepper = (r, w, length, ctx) => {
|
|
830
|
+
if (physicsMode === 'off') return false;
|
|
830
831
|
const moved = inner(r, w, length, ctx);
|
|
831
832
|
if (ctx.time > 0 && ctx.states.get(r.name)?.lastTime === ctx.time) steppedNow.add(r.name);
|
|
832
833
|
return moved;
|
package/src/render_shared.ts
CHANGED
|
@@ -243,6 +243,8 @@ export interface FrameSet {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
export interface FramesSidecar {
|
|
246
|
+
/** Absent means on, including frame sets written before this field existed. */
|
|
247
|
+
physicsMode?: 'off';
|
|
246
248
|
spec: string;
|
|
247
249
|
example?: string;
|
|
248
250
|
rung?: string;
|
|
@@ -427,6 +429,8 @@ export interface UvWindow {
|
|
|
427
429
|
|
|
428
430
|
/** Options for `piecesOf` and the samplers that call it. */
|
|
429
431
|
export interface PoseOptions {
|
|
432
|
+
/** Suppress every physics constraint after animation application. */
|
|
433
|
+
physicsMode?: 'off';
|
|
430
434
|
/** Also read local bone/deform channels and mapped slider times, and retain original attachment source identity outside JSON, for --extremes. */
|
|
431
435
|
channels?: boolean;
|
|
432
436
|
/** Also record each piece's original-art UVs — see `PieceTexture`. */
|
|
@@ -1184,7 +1188,7 @@ export interface Poser {
|
|
|
1184
1188
|
/** `slotSubsetOf` against this skeleton posed under `skin` — refused by `SlotSubsetError`. */
|
|
1185
1189
|
subset(opts: Pick<PoseOptions, 'slots' | 'hidden'> | undefined, skin: string | undefined): SlotSubset | undefined;
|
|
1186
1190
|
/** The setup pose under `skin` (absent: no skin set at all). */
|
|
1187
|
-
setup(skin: string | undefined, channels?: boolean): Posed;
|
|
1191
|
+
setup(skin: string | undefined, channels?: boolean, physicsMode?: 'off'): Posed;
|
|
1188
1192
|
/**
|
|
1189
1193
|
* Animation `name` under `skin`, not looping: `visit(i, posed)` for every
|
|
1190
1194
|
* `i` from 0 to `count`, the pose at `i/fps`. The caller has checked `name`.
|
|
@@ -1205,7 +1209,7 @@ export interface Poser {
|
|
|
1205
1209
|
* follows `times` (`prerollTimes` says why the two part). Absent, every
|
|
1206
1210
|
* step moves the physics clock by its own length, as it always did.
|
|
1207
1211
|
*/
|
|
1208
|
-
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean, times?: readonly number[], loop?: boolean, clockSteps?: readonly number[]): void;
|
|
1212
|
+
animation(name: string, skin: string | undefined, fps: number, count: number, visit: (index: number, posed: Posed) => void, environment?: (index: number) => EnvironmentChange | undefined, channels?: boolean, times?: readonly number[], loop?: boolean, clockSteps?: readonly number[], physicsMode?: 'off'): void;
|
|
1209
1213
|
/** The rest table for the (slot, attachment) pairs `shown` holds — see `AttachmentRest`. */
|
|
1210
1214
|
rest(skin: string | undefined, shown: readonly AttachmentPose[][]): AttachmentRest[];
|
|
1211
1215
|
}
|
|
@@ -1290,14 +1294,15 @@ export function sampleAnimation(source: PoseSource, name: string, fps: number, o
|
|
|
1290
1294
|
const recorded = (i: number, posed: Posed): void => {
|
|
1291
1295
|
if (i >= first) frames.push(frameOf(i - first, (i - first) * step, posed, draw(), opts));
|
|
1292
1296
|
};
|
|
1293
|
-
poser.animation(name, opts?.skin, fps, times.length - 1, recorded, undefined, opts?.channels === true ? true : undefined, times, true, clock);
|
|
1297
|
+
poser.animation(name, opts?.skin, fps, times.length - 1, recorded, undefined, opts?.channels === true ? true : undefined, times, true, clock, opts?.physicsMode);
|
|
1294
1298
|
return frames;
|
|
1295
1299
|
}
|
|
1296
1300
|
const visit = (i: number, posed: Posed): void => {
|
|
1297
1301
|
frames.push(frameOf(i, i * step, posed, draw(), opts));
|
|
1298
1302
|
};
|
|
1299
1303
|
// Without an environment the call is the one it always was, so a poser that takes none is asked nothing new.
|
|
1300
|
-
if (opts?.
|
|
1304
|
+
if (opts?.physicsMode === 'off') poser.animation(name, opts.skin, fps, count, visit, opts.environment === undefined ? undefined : environmentAt(opts.environment, fps), opts.channels, undefined, undefined, undefined, 'off');
|
|
1305
|
+
else if (opts?.channels) poser.animation(name, opts.skin, fps, count, visit, opts.environment === undefined ? undefined : environmentAt(opts.environment, fps), true);
|
|
1301
1306
|
else if (opts?.environment === undefined) poser.animation(name, opts?.skin, fps, count, visit);
|
|
1302
1307
|
else poser.animation(name, opts.skin, fps, count, visit, environmentAt(opts.environment, fps));
|
|
1303
1308
|
return frames;
|
|
@@ -1413,7 +1418,7 @@ export function prerollTimes(duration: number, fps: number, preroll: number): {
|
|
|
1413
1418
|
*/
|
|
1414
1419
|
export function sampleSetupPose(source: PoseSource, opts?: PoseOptions): Frame[] {
|
|
1415
1420
|
const poser = poserOf(source);
|
|
1416
|
-
const posed = opts?.channels ? poser.setup(opts.skin, true) : poser.setup(opts?.skin);
|
|
1421
|
+
const posed = opts?.physicsMode === 'off' ? poser.setup(opts.skin, opts.channels, 'off') : opts?.channels ? poser.setup(opts.skin, true) : poser.setup(opts?.skin);
|
|
1417
1422
|
return [frameOf(0, 0, posed, drawResolver(poser, opts)(), opts)];
|
|
1418
1423
|
}
|
|
1419
1424
|
|
|
@@ -1737,6 +1742,7 @@ export interface GeometryFile {
|
|
|
1737
1742
|
/** The setup pose, sampled the way `sampleSetupPose` samples it. */
|
|
1738
1743
|
setup: Omit<GeometryFrame, 'index' | 'time'>;
|
|
1739
1744
|
frames: GeometryFrame[];
|
|
1745
|
+
physicsMode?: 'off';
|
|
1740
1746
|
}
|
|
1741
1747
|
|
|
1742
1748
|
/**
|
|
@@ -1784,6 +1790,7 @@ export function geometryFileOf(
|
|
|
1784
1790
|
skin: string | undefined,
|
|
1785
1791
|
environment?: EnvironmentStep[],
|
|
1786
1792
|
preroll?: number,
|
|
1793
|
+
physicsMode?: 'off',
|
|
1787
1794
|
): GeometryFile {
|
|
1788
1795
|
const geometryFrame = (frame: Frame, where: string): Omit<GeometryFrame, 'index' | 'time'> => {
|
|
1789
1796
|
if (frame.bones === undefined || frame.attachments === undefined) {
|
|
@@ -1800,7 +1807,7 @@ export function geometryFileOf(
|
|
|
1800
1807
|
...geometryFrame(frame, `frame ${frame.index}`),
|
|
1801
1808
|
}));
|
|
1802
1809
|
const poser = poserOf(source);
|
|
1803
|
-
const setupFrame = sampleSetupPose(poser, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true })[0];
|
|
1810
|
+
const setupFrame = sampleSetupPose(poser, { ...(skin === undefined ? {} : { skin }), bones: true, geometry: true, ...(physicsMode === undefined ? {} : { physicsMode }) })[0];
|
|
1804
1811
|
const setup = geometryFrame(setupFrame, 'the setup pose');
|
|
1805
1812
|
const file: GeometryFile = {
|
|
1806
1813
|
spec: GEOMETRY_SPEC,
|
|
@@ -1809,6 +1816,7 @@ export function geometryFileOf(
|
|
|
1809
1816
|
...(skin === undefined ? {} : { skin }),
|
|
1810
1817
|
...(environment === undefined ? {} : { environment }),
|
|
1811
1818
|
...(preroll === undefined ? {} : { preroll }),
|
|
1819
|
+
...(physicsMode === undefined ? {} : { physicsMode }),
|
|
1812
1820
|
fps,
|
|
1813
1821
|
viewport: sidecarViewport(viewport),
|
|
1814
1822
|
bones: poser.bones.map(({ name, parent }) => ({ name, parent })),
|
package/src/seam.ts
CHANGED
|
@@ -133,6 +133,69 @@ export interface SeamFrame {
|
|
|
133
133
|
/** A world box `[minX, minY, maxX, maxY]`, world units, y up. */
|
|
134
134
|
export type SeamBox = [number, number, number, number];
|
|
135
135
|
|
|
136
|
+
/** One counted gap cell: centre in world units, y up. */
|
|
137
|
+
export interface SeamCell {
|
|
138
|
+
x: number;
|
|
139
|
+
y: number;
|
|
140
|
+
setupTop: string | null;
|
|
141
|
+
setupUnder: string | null;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Optional detail; absent preserves the existing sidecar bytes. */
|
|
145
|
+
export interface SeamOptions {
|
|
146
|
+
cells?: SeamCellReader;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Fills setup attribution for counted cells; reusable across pairs and frame sets. */
|
|
150
|
+
export type SeamCellReader = (cells: SeamCell[], side: number) => void;
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Lazy setup raster, once per distinct cell side. Slot occupancy uses the same
|
|
154
|
+
* positive rendered-alpha traversal as gap coverage, without source-over hiding
|
|
155
|
+
* the layer below. Repeated fragments of one slot are one layer.
|
|
156
|
+
*/
|
|
157
|
+
export function setupCellReader(setup: () => readonly Piece[], pages: ReadonlyMap<string, Plate>): SeamCellReader {
|
|
158
|
+
const cache = new Map<number, { viewport: Viewport; names: string[]; top: Int32Array; under: Int32Array } | null>();
|
|
159
|
+
let pieces: readonly Piece[] | undefined;
|
|
160
|
+
return (cells, side) => {
|
|
161
|
+
if (cells.length === 0) return;
|
|
162
|
+
if (!cache.has(side)) {
|
|
163
|
+
pieces ??= setup();
|
|
164
|
+
if (pieces.length === 0) cache.set(side, null);
|
|
165
|
+
else {
|
|
166
|
+
const { viewport } = gridOf([], side, pieces);
|
|
167
|
+
const top = new Int32Array(viewport.width * viewport.height).fill(-1);
|
|
168
|
+
const under = new Int32Array(top.length).fill(-1);
|
|
169
|
+
const names = [...new Set(pieces.map(p => p.slot))];
|
|
170
|
+
const ids = new Map(names.map((name, i) => [name, i]));
|
|
171
|
+
const project = projector(viewport);
|
|
172
|
+
for (const piece of pieces) {
|
|
173
|
+
const page = pages.get(piece.page);
|
|
174
|
+
if (page === undefined) throw new SeamError(`setup slot "${piece.slot}" samples missing atlas page "${piece.page}"; available [${[...pages.keys()].join(', ')}]`);
|
|
175
|
+
const id = ids.get(piece.slot)!;
|
|
176
|
+
rasterisePiece(page, piece, project, viewport, (x, y, _r, _g, _b, alpha) => {
|
|
177
|
+
const k = y * viewport.width + x;
|
|
178
|
+
if (alpha > 0 && top[k] !== id) { under[k] = top[k]; top[k] = id; }
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
cache.set(side, { viewport, names, top, under });
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const read = cache.get(side);
|
|
185
|
+
for (const cell of cells) {
|
|
186
|
+
cell.setupTop = null; cell.setupUnder = null;
|
|
187
|
+
if (read == null) continue;
|
|
188
|
+
const { viewport, names, top, under } = read;
|
|
189
|
+
const x = Math.round((cell.x - viewport.minX) / side - 0.5);
|
|
190
|
+
const y = Math.round((viewport.maxY - cell.y) / side - 0.5);
|
|
191
|
+
if (x < 0 || y < 0 || x >= viewport.width || y >= viewport.height) continue;
|
|
192
|
+
const k = y * viewport.width + x;
|
|
193
|
+
cell.setupTop = names[top[k]] ?? null;
|
|
194
|
+
cell.setupUnder = names[under[k]] ?? null;
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
136
199
|
/** What one frame reads of one kind. */
|
|
137
200
|
export interface SeamHit {
|
|
138
201
|
index: number;
|
|
@@ -146,6 +209,8 @@ export interface SeamHit {
|
|
|
146
209
|
area: number;
|
|
147
210
|
/** The counted cells' whole squares, world units, y up. */
|
|
148
211
|
box: SeamBox;
|
|
212
|
+
/** Opt-in on gapOpen/gapCovered only; descending y, then ascending x. */
|
|
213
|
+
cells?: SeamCell[];
|
|
149
214
|
}
|
|
150
215
|
|
|
151
216
|
/** A run of consecutive frames of one kind — first and last frame, their times, the most cells on any of them. */
|
|
@@ -385,6 +450,7 @@ interface Side {
|
|
|
385
450
|
interface Cells {
|
|
386
451
|
px: number;
|
|
387
452
|
box: SeamBox | null;
|
|
453
|
+
cells?: SeamCell[];
|
|
388
454
|
}
|
|
389
455
|
|
|
390
456
|
const restPiece = (side: Side): Piece => ({ ...side.piece, world: Array.from(side.rest) });
|
|
@@ -530,7 +596,7 @@ function contactOf(restA: Uint8Array, restB: Uint8Array, drawnA: Uint8Array, dra
|
|
|
530
596
|
}
|
|
531
597
|
|
|
532
598
|
/** One frame's counts, or `null` when the two drawings share no cell at the setup pose. See the module comment. */
|
|
533
|
-
function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: SeamFrame, pages: ReadonlyMap<string, Plate>, grid: Grid, visibility: readonly Uint8Array[]): { gap: Cells; ghost: Cells; ghostA: Cells; ghostB: Cells; gapOpen: Cells; gapCovered: Cells; contact: Contact } | null {
|
|
599
|
+
function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: SeamFrame, pages: ReadonlyMap<string, Plate>, grid: Grid, visibility: readonly Uint8Array[], options: SeamOptions): { gap: Cells; ghost: Cells; ghostA: Cells; ghostB: Cells; gapOpen: Cells; gapCovered: Cells; contact: Contact } | null {
|
|
534
600
|
const { width, height } = grid.viewport;
|
|
535
601
|
const restA = restPiece(A);
|
|
536
602
|
const restB = restPiece(B);
|
|
@@ -589,8 +655,8 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
|
|
|
589
655
|
const ghost: Cells = { px: 0, box: null };
|
|
590
656
|
const ghostA: Cells = { px: 0, box: null };
|
|
591
657
|
const ghostB: Cells = { px: 0, box: null };
|
|
592
|
-
const gapOpen: Cells = { px: 0, box: null };
|
|
593
|
-
const gapCovered: Cells = { px: 0, box: null };
|
|
658
|
+
const gapOpen: Cells = { px: 0, box: null, ...(options.cells ? { cells: [] } : {}) };
|
|
659
|
+
const gapCovered: Cells = { px: 0, box: null, ...(options.cells ? { cells: [] } : {}) };
|
|
594
660
|
let alphaCells: Uint8Array | null = null;
|
|
595
661
|
const take = (into: Cells, qx: number, qy: number): void => takeCell(into, qx, qy, cell);
|
|
596
662
|
if (plant.never) return { gap, ghost, ghostA, ghostB, gapOpen, gapCovered, contact };
|
|
@@ -610,7 +676,9 @@ function readFrame(A: Side, B: Side, cell: number, plant: SeamPlant, frame: Seam
|
|
|
610
676
|
take(gap, qx, qy);
|
|
611
677
|
const covered = plant.gapCoverage === 'covered' || (plant.gapCoverage !== 'open' &&
|
|
612
678
|
(alphaCells ??= frameAlphaCells(plant.gapCoverage === 'pair' ? [A.piece, B.piece] : frame.pieces, pages, grid.viewport, plant.gapCoverage === 'half'))[k] !== 0);
|
|
613
|
-
|
|
679
|
+
const into = covered ? gapCovered : gapOpen;
|
|
680
|
+
take(into, qx, qy);
|
|
681
|
+
into.cells?.push({ x: qx, y: qy, setupTop: null, setupUnder: null });
|
|
614
682
|
}
|
|
615
683
|
continue;
|
|
616
684
|
}
|
|
@@ -665,6 +733,7 @@ export function seamsOf(
|
|
|
665
733
|
frames: readonly SeamFrame[],
|
|
666
734
|
pages: ReadonlyMap<string, Plate>,
|
|
667
735
|
plant: SeamPlant = {},
|
|
736
|
+
options: SeamOptions = {},
|
|
668
737
|
): SeamPairReading[] {
|
|
669
738
|
const restOf = new Map(rest.map((r) => [restKey(r.slot, r.attachment), r]));
|
|
670
739
|
const sideOf = (frame: SeamFrame, slot: string): Side | null => {
|
|
@@ -711,13 +780,17 @@ export function seamsOf(
|
|
|
711
780
|
if (A === null || B === null) continue;
|
|
712
781
|
drawn++;
|
|
713
782
|
const cell = exposureCell;
|
|
714
|
-
const read = readFrame(A, B, cell, plant, frame, pages, exposureGrid, exposure.map((m) => m.visible));
|
|
783
|
+
const read = readFrame(A, B, cell, plant, frame, pages, exposureGrid, exposure.map((m) => m.visible), options);
|
|
715
784
|
if (read === null) { contact(unavailableContact('no shared half-opacity cell at the setup pose; no band to identify a joint')); continue; }
|
|
716
785
|
const fragmented = [a, b].some(slot => frame.pieces.filter(p => p.slot === slot).length !== 1);
|
|
717
786
|
contact(fragmented ? unavailableContact('each named slot must draw exactly one fragment for material contact') : read.contact);
|
|
718
787
|
banded++;
|
|
788
|
+
if (options.cells) {
|
|
789
|
+
options.cells(read.gapOpen.cells!, cell);
|
|
790
|
+
options.cells(read.gapCovered.cells!, cell);
|
|
791
|
+
}
|
|
719
792
|
for (const [cells, into] of [[read.gap, gaps], [read.ghost, ghosts], [read.ghostA, ghostsA], [read.ghostB, ghostsB], [read.gapOpen, openGaps], [read.gapCovered, coveredGaps]] as const) {
|
|
720
|
-
if (cells.px > 0 && cells.box !== null) into.push({ index: frame.index, time: frame.time, px: cells.px, cell, area: cells.px * cell * cell, box: cells.box });
|
|
793
|
+
if (cells.px > 0 && cells.box !== null) into.push({ index: frame.index, time: frame.time, px: cells.px, cell, area: cells.px * cell * cell, box: cells.box, ...(cells.cells === undefined ? {} : { cells: cells.cells }) });
|
|
721
794
|
}
|
|
722
795
|
}
|
|
723
796
|
const gap = kindOf(gaps);
|
package/src/sheet.ts
CHANGED
|
@@ -120,6 +120,7 @@ export interface SheetManifest {
|
|
|
120
120
|
environment?: EnvironmentStep[];
|
|
121
121
|
/** The whole loops walked before the loop these frames record (`render --preroll`, issue #1684); absent without it. */
|
|
122
122
|
preroll?: number;
|
|
123
|
+
physicsMode?: 'off';
|
|
123
124
|
pinned?: PinnedViewport;
|
|
124
125
|
fps: number;
|
|
125
126
|
duration: number;
|
|
@@ -185,6 +186,7 @@ export interface BuildSheetSetOptions {
|
|
|
185
186
|
subset?: SlotSubset;
|
|
186
187
|
environment?: EnvironmentStep[];
|
|
187
188
|
preroll?: number;
|
|
189
|
+
physicsMode?: 'off';
|
|
188
190
|
pinned?: PinnedViewport;
|
|
189
191
|
/** For negative-control testing in `selftest.ts` only: reverse the input order handed to `packAtlas`. */
|
|
190
192
|
reversePackInputs?: boolean;
|
|
@@ -276,6 +278,7 @@ export function sheetNamespaceOf(opts: {
|
|
|
276
278
|
subset?: SlotSubset;
|
|
277
279
|
environment?: EnvironmentStep[];
|
|
278
280
|
preroll?: number;
|
|
281
|
+
physicsMode?: 'off';
|
|
279
282
|
}): string {
|
|
280
283
|
const rig = sanitizeNamespaceSegment(opts.rigName);
|
|
281
284
|
const anim = sanitizeNamespaceSegment(opts.setName);
|
|
@@ -287,6 +290,7 @@ export function sheetNamespaceOf(opts: {
|
|
|
287
290
|
variants.push(`env=${digest}`);
|
|
288
291
|
}
|
|
289
292
|
if (opts.preroll !== undefined) variants.push(`preroll=${opts.preroll}`);
|
|
293
|
+
if (opts.physicsMode !== undefined) variants.push(`physics=${opts.physicsMode}`);
|
|
290
294
|
return variants.length === 0 ? `${rig}/${anim}` : `${rig}/${anim}/${variants.join('+')}`;
|
|
291
295
|
}
|
|
292
296
|
|
|
@@ -721,6 +725,7 @@ export function buildSheetSet(opts: BuildSheetSetOptions): SheetSetPack {
|
|
|
721
725
|
subset: opts.subset,
|
|
722
726
|
environment: opts.environment,
|
|
723
727
|
preroll: opts.preroll,
|
|
728
|
+
physicsMode: opts.physicsMode,
|
|
724
729
|
});
|
|
725
730
|
|
|
726
731
|
const padding = SHEET_PADDING;
|
|
@@ -875,6 +880,7 @@ export function buildSheetSet(opts: BuildSheetSetOptions): SheetSetPack {
|
|
|
875
880
|
: { hidden: [...opts.subset.names] }),
|
|
876
881
|
...(opts.environment === undefined ? {} : { environment: opts.environment }),
|
|
877
882
|
...(opts.preroll === undefined ? {} : { preroll: opts.preroll }),
|
|
883
|
+
...(opts.physicsMode === undefined ? {} : { physicsMode: opts.physicsMode }),
|
|
878
884
|
...(opts.pinned === undefined ? {} : { pinned: opts.pinned }),
|
|
879
885
|
fps: opts.fps,
|
|
880
886
|
duration,
|