spine-rigc 3.2.1 → 3.3.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> |
@@ -9950,6 +9951,53 @@ still refused above 0, would reach the same looping walk; counted in steps rathe
9950
9951
  than loops, it would move every compared frame off its `FrameRef` time unless the
9951
9952
  steps were whole loops.
9952
9953
 
9954
+ ### 8.14 Physics-off renders and boundary comparisons
9955
+
9956
+ `rigc render --candidate <dir> --physics off --out <off>` suppresses every physics
9957
+ constraint on both posers, after the animation timelines apply. Every other
9958
+ constraint and the animation clock keep their ordinary walk. `--physics on` is the
9959
+ default. Any other value is refused by name. An omitted mode retains the existing
9960
+ render bytes. Off records `physicsMode: "off"` in `frames.json`, geometry and sheet
9961
+ metadata, and adds a `physics=off` variant to sheet frame keys. A reference
9962
+ without the field means on. `check` refuses an off reference because its candidate
9963
+ is posed with physics on.
9964
+
9965
+ To compare two whole-figure renders, first render the reference, then render the
9966
+ second with `--viewport-from <reference-dir> --boundary-from <reference-dir>`.
9967
+ For example, render the public gallery physics recipe normally, then render it
9968
+ with those two flags and `--physics off`. Use the same animation selection, skin,
9969
+ fps and pre-roll on both runs. The shared viewport fixes the world box, scale and
9970
+ pixel dimensions. Check the viewport's recorded `pinned.outside` when interpreting
9971
+ an outline that reaches the canvas edge.
9972
+
9973
+ `boundary.json` (`rigc-boundary-difference/1`) records each matching frame's maximum
9974
+ symmetric nearest-boundary distance in **L-infinity pixels**, and the maximum over
9975
+ the frames read. A boundary pixel is visible foreground with at least one
9976
+ four-neighbour background pixel, or a foreground pixel on the canvas edge. Holes
9977
+ contribute boundaries too. Foreground means the rendered RGBA differs from the
9978
+ recorded background exactly. Background-coloured art has no observable coverage
9979
+ in this reading, and anything outside the viewport is absent. This is a pixel
9980
+ reading with no registration, threshold or pass verdict.
9981
+
9982
+ An identical on/on pair reads zero. An on/off pair measures the visible outline
9983
+ change on the chosen grid. Empty boundaries report `SKIP`, never zero. A frame
9984
+ schedule, viewport, background, skin, set roster or file mismatch also reports
9985
+ `SKIP` with a reason. Missing or unreadable frames remain unavailable. Mixed
9986
+ readable and empty frames report `PARTIAL` with the per-frame reasons. Both sides
9987
+ 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
9988
+ full-canvas opaque backdrop can make the distance zero even while the character
9989
+ moves. For `gallery/look`, use `--hide plate` on both renders to read the character
9990
+ without its backdrop. Every other drawn slot still contributes to that outline.
9991
+ `--extremes-only` takes no `--boundary-from`, and the reference directory must differ
9992
+ from `--out`. Reusing an output directory clears its previous `boundary.json`
9993
+ before writing the new render, including an `--extremes-only` render.
9994
+
9995
+ `--extremes` chooses a pair within one animation and reads corresponding authored
9996
+ vertices and local channels. Subtracting two extreme readings does not compare
9997
+ the same frames' visible outlines. `diff` compares skeleton data, and `check`
9998
+ reposes and registers a candidate against reference pictures. Neither reads two
9999
+ already-rendered whole-figure boundaries on an unchanged grid.
10000
+
9953
10001
  ## 9. Checking against the frames — `rigc check`
9954
10002
 
9955
10003
  ```bash
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spine-rigc",
3
- "version": "3.2.1",
3
+ "version": "3.3.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';
@@ -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', '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
@@ -850,7 +854,7 @@ export function cmdRender(flags: Record<string, string>): void {
850
854
  posersAsked(flags),
851
855
  );
852
856
  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']) {
857
+ 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', 'physics', 'boundary-from', 'viewport', 'viewport-from']) {
854
858
  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
859
  }
856
860
  if (flags.poser !== undefined && flags.poser !== 'core') throw new UsageError('--parallax requires --poser core: it reads only a rigc compiled model');
@@ -898,13 +902,14 @@ export function cmdRender(flags: Record<string, string>): void {
898
902
  // what the frames under it draw.
899
903
  // The pre-roll rides on it for the environment's reason (issue #1684): it changes the pose, so the framing reads it.
900
904
  const pose =
901
- skin === undefined && subset === undefined && environment === undefined && preroll === undefined
905
+ skin === undefined && subset === undefined && environment === undefined && preroll === undefined && physicsMode === undefined
902
906
  ? undefined
903
907
  : {
904
908
  ...(skin === undefined ? {} : { skin }),
905
909
  ...subsetFields(subset),
906
910
  ...(environment === undefined ? {} : { environment: environment.steps }),
907
911
  ...(preroll === undefined ? {} : { preroll }),
912
+ ...(physicsMode === undefined ? {} : { physicsMode }),
908
913
  };
909
914
  if (skin !== undefined) console.log(` .. skin ${skin}`);
910
915
  if (subset !== undefined) console.log(` .. ${subset.mode.padEnd(8)} ${subset.names.join(', ')}`);
@@ -1019,7 +1024,7 @@ export function cmdRender(flags: Record<string, string>): void {
1019
1024
  if (geometry) {
1020
1025
  for (const [name, frames] of sampled) {
1021
1026
  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)));
1027
+ geometryTexts.set(name, geometryText(geometryFileOf(poser, animation, fps, frames, viewport, skin, environment?.steps, preroll, physicsMode)));
1023
1028
  }
1024
1029
  }
1025
1030
  // The fold reading, off the very attachments the frames were drawn from, against the rest table the geometry
@@ -1151,6 +1156,7 @@ export function cmdRender(flags: Record<string, string>): void {
1151
1156
  subset,
1152
1157
  environment: environment?.steps,
1153
1158
  preroll,
1159
+ physicsMode,
1154
1160
  pinned,
1155
1161
  }),
1156
1162
  );
@@ -1194,6 +1200,8 @@ export function cmdRender(flags: Record<string, string>): void {
1194
1200
  }
1195
1201
 
1196
1202
  mkdirSync(outRoot, { recursive: true });
1203
+ // A prior observation cannot describe the new frames, including an observation-only render.
1204
+ rmSync(join(outRoot, 'boundary.json'), { force: true });
1197
1205
  if (extremesOnly) {
1198
1206
  // Reuse the same posed grid and plates; selecting extremes needs no ordinary pixels.
1199
1207
  // Clear only the selected sets, just as the ordinary render does. No frames.json
@@ -1289,7 +1297,7 @@ export function cmdRender(flags: Record<string, string>): void {
1289
1297
  : ` + ${sheetPack.pages.map((p) => `${p.image} + ${p.table}`).join(' + ')} + ${SHEET_MANIFEST_FILE} (${sheetPack.pages.length} page(s), ${sheetPack.pageEdges})`;
1290
1298
  const extras = `${sheet ? ` + ${SHEET_FILE}` : ''}${sheetNote}${geometryOut === undefined ? '' : ` + ${GEOMETRY_FILE}`}${foldNote}${passNote}${seamNote}${overlapNote}`;
1291
1299
  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)}`);
1300
+ if (physics !== undefined) console.log(` .. ${physicsLine(physics.stepped, physicsDeclared, fps, frames.length, physicsMode)}`);
1293
1301
  for (const mesh of foldReading?.meshes ?? []) {
1294
1302
  const key = `${mesh.slot}\u0000${mesh.attachment}`;
1295
1303
  const fault = faultsSaid.has(key) ? null : setupFaultLine(mesh);
@@ -1330,6 +1338,7 @@ export function cmdRender(flags: Record<string, string>): void {
1330
1338
  ...(environment === undefined ? {} : { environment: environment.steps }),
1331
1339
  // Written only when a pre-roll was asked for (issue #1684), for the same reason.
1332
1340
  ...(preroll === undefined ? {} : { preroll }),
1341
+ ...(physicsMode === undefined ? {} : { physicsMode }),
1333
1342
  // Written only when the box was given (issue #1414): a fitted render says nothing and stays the bytes it was.
1334
1343
  ...(given === undefined ? {} : { pinned: pinnedRecord(given.by, outside) }),
1335
1344
  background: BACKGROUND,
@@ -1340,6 +1349,13 @@ export function cmdRender(flags: Record<string, string>): void {
1340
1349
  };
1341
1350
  writeFileSync(join(outRoot, FRAMES_SIDECAR), `${JSON.stringify(sidecar, null, 2)}\n`);
1342
1351
  console.log(`rigc: wrote ${join(outRoot, FRAMES_SIDECAR)}`);
1352
+ if (flags['boundary-from'] !== undefined) {
1353
+ const reading = boundaryComparison(resolve(flags['boundary-from']), outRoot, sidecar);
1354
+ writeFileSync(join(outRoot, 'boundary.json'), `${JSON.stringify(reading, null, 2)}\n`);
1355
+ const detail = 'reason' in reading ? reading.reason : `max ${String(reading.maxPixels)} L-infinity px over ${reading.frames.length} frame(s)`;
1356
+ console.log(` .. boundary ${reading.status}: ${detail}`);
1357
+ console.log(`rigc: wrote ${join(outRoot, 'boundary.json')}`);
1358
+ }
1343
1359
  }
1344
1360
 
1345
1361
  /**
@@ -1371,11 +1387,11 @@ export function physicsSteppedOf(frames: readonly Frame[], fps: number): { stepp
1371
1387
  * `render`'s `physics:` line for one set (issue #1467): how many constraints were stepped at the set's rate, and — when
1372
1388
  * fewer than the rig declares — the ones that took no step, by name, with why a constraint takes none.
1373
1389
  */
1374
- export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number): string {
1390
+ export function physicsLine(stepped: readonly string[], declared: readonly string[], fps: number, frames: number, physicsMode?: 'off'): string {
1375
1391
  const head = `physics: ${stepped.length} constraint(s) stepped at ${fps} fps`;
1376
1392
  const idle = declared.filter((name) => !stepped.includes(name));
1377
1393
  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';
1394
+ 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
1395
  return `${head} — ${idle.length} of the ${declared.length} declared took none: ${idle.map((n) => JSON.stringify(n)).join(', ')} (${why})`;
1380
1396
  }
1381
1397
 
package/src/cli/shared.ts CHANGED
@@ -1108,6 +1108,8 @@ const FLAG_MEANINGS: Record<string, string> = {
1108
1108
  'runtime\'s, wind [1, 0] and gravity [0, 1]. frames.json and geometry.json record it, and the framing reads it. A ' +
1109
1109
  'malformed file, a half no physics constraint answers, two steps on one frame and a step after every set\'s last frame ' +
1110
1110
  'are refused by name. An inspection of what the rig offers, not a scene',
1111
+ 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',
1112
+ '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
1113
  preroll:
1112
1114
  'walk each set this many whole loops on a looping track before the loop recorded, drawn by no frame, the physics carried ' +
1113
1115
  '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 +1309,8 @@ const FLAG_VALUES: Record<string, string> = {
1307
1309
  'texture-from': '<path>',
1308
1310
  poser: 'core|spine',
1309
1311
  environment: '<schedule.json>',
1312
+ physics: '<on|off>',
1313
+ 'boundary-from': '<dir>',
1310
1314
  preroll: '<n>',
1311
1315
  'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
1312
1316
  seam: '<slot>:<slot>[,<slot>:<slot>…]',
@@ -1785,7 +1789,7 @@ export const COMMANDS: CommandDoc[] = [
1785
1789
  'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1786
1790
  'rigc render … --sheet [--sheet-format json-hash] [--page-edges pot|free] (deterministic sprite sheets + JSON hash table + sheet-manifest.json per set)',
1787
1791
  ],
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'],
1792
+ 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', 'physics', 'boundary-from', 'poser', 'out'],
1789
1793
  overrides: {
1790
1794
  'page-edges': {
1791
1795
  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/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,