rig-c 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 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": "rig-c",
3
- "version": "3.2.1",
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": {
@@ -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;
@@ -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
 
@@ -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;
@@ -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?.channels) poser.animation(name, opts.skin, fps, count, visit, opts.environment === undefined ? undefined : environmentAt(opts.environment, fps), true);
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
- take(covered ? gapCovered : gapOpen, qx, qy);
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,