spine-rigc 2.55.0 → 2.56.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.
@@ -0,0 +1,190 @@
1
+ /**
2
+ * How far one bone turns against another over an animation — the reading behind
3
+ * `rigc render --angle-range <bone>:<relative-to>[,…]` (issue #1414).
4
+ *
5
+ * ⭐ **A reading, not a gate**, as `--folds` and `--pass-through` are. A physics
6
+ * value has no scale an author can see: §4.6 lists `inertia`, `strength` and the
7
+ * rest with their bounds, and nothing said what `inertia` 0.5 does to a 54-unit
8
+ * chain under a hip swing — the first try on a real rig was four times too big.
9
+ * The answer is a number the pose already holds: how far the link turned against
10
+ * the bone it hangs from. So this reads it off the frames `render` draws and
11
+ * decides nothing.
12
+ *
13
+ * ## What the range is
14
+ *
15
+ * At each frame the bones are posed by whichever poser draws the frames, and each
16
+ * bone's **world direction** is that poser's own `rotationX` — the direction its x
17
+ * axis points, degrees counter-clockwise, y up (`BoneSnapshot`), never a second
18
+ * opinion recomputed from the matrix here. The relative angle is
19
+ *
20
+ * rotationX(bone) − rotationX(relative-to)
21
+ *
22
+ * taken into (−180, 180] on the first frame read, and then **followed**: each
23
+ * later frame's difference is moved by whole turns to within half a turn of the
24
+ * frame before it. So a link that sweeps 200° reads 200°, not −160°, and one that
25
+ * sways through ±180° does not read a jump of a whole turn. Following assumes no
26
+ * two consecutive frames are half a turn or more apart, which a frame grid that
27
+ * coarse could not show anyway.
28
+ *
29
+ * ⚠️ It is the angle **drawn**, not the bone's local `rotation`. The two agree for
30
+ * a child of the other bone when the parent carries no non-uniform scale or shear;
31
+ * otherwise they differ, and the drawn one is what a viewer sees.
32
+ *
33
+ * The range is over the frames on the render's own grid — `--fps`, from t = 0 to
34
+ * the animation's last frame — so a peak between two frames is not read. A bone
35
+ * the posed skin leaves unposed, or one scaled to nothing, has no direction: its
36
+ * world matrix is zero, and the reading is refused naming the bone and the frame
37
+ * rather than read as 0°.
38
+ *
39
+ * Pure: no clock, no randomness, no runtime. The bone snapshots are handed in by
40
+ * whichever poser drew the frames.
41
+ */
42
+ import type { BoneSnapshot } from './render_shared.ts';
43
+
44
+ /** One pair as the flag names it: the bone whose angle is read, and the bone it is read against. */
45
+ export interface BonePair {
46
+ bone: string;
47
+ relativeTo: string;
48
+ }
49
+
50
+ /** A malformed `--angle-range` value, or a pose the reading has no angle for — said by name. */
51
+ export class AngleRangeError extends Error {}
52
+
53
+ /**
54
+ * `--angle-range`'s value — `<bone>:<relative-to>[,<bone>:<relative-to>…]` —
55
+ * read against the skeleton's bones, or refused naming the entry: an empty
56
+ * entry, one without exactly one `:`, an empty name, a bone the skeleton does not
57
+ * have (with the bones it does), a bone read against itself (always 0°), and a
58
+ * pair named twice.
59
+ */
60
+ export function readBonePairs(raw: string, bones: readonly string[]): BonePair[] {
61
+ const form = '<bone>:<relative-to>[,<bone>:<relative-to>…]';
62
+ const pairs: BonePair[] = [];
63
+ const entries = raw.split(',');
64
+ for (let i = 0; i < entries.length; i++) {
65
+ const entry = entries[i].trim();
66
+ if (entry === '') throw new AngleRangeError(`--angle-range ${JSON.stringify(raw)}: entry ${i} is empty — it takes ${form}`);
67
+ const parts = entry.split(':');
68
+ if (parts.length !== 2) {
69
+ throw new AngleRangeError(`--angle-range: entry ${i} ${JSON.stringify(entry)} has ${parts.length - 1} ":" — it takes ${form}, one ":" per pair`);
70
+ }
71
+ const [bone, relativeTo] = parts.map((p) => p.trim());
72
+ for (const [role, name] of [['bone', bone], ['relative-to bone', relativeTo]] as const) {
73
+ if (name === '') throw new AngleRangeError(`--angle-range: entry ${i} ${JSON.stringify(entry)} names no ${role} — it takes ${form}`);
74
+ if (!bones.includes(name)) {
75
+ throw new AngleRangeError(`--angle-range: entry ${i} names ${role} ${JSON.stringify(name)}, which this skeleton does not have; its bones are [${bones.join(', ')}]`);
76
+ }
77
+ }
78
+ if (bone === relativeTo) {
79
+ throw new AngleRangeError(`--angle-range: entry ${i} reads bone ${JSON.stringify(bone)} against itself, which is 0° on every frame — name a second bone`);
80
+ }
81
+ const again = pairs.findIndex((p) => p.bone === bone && p.relativeTo === relativeTo);
82
+ if (again >= 0) throw new AngleRangeError(`--angle-range: entry ${i} names "${bone}:${relativeTo}" again — entry ${again} already reads it`);
83
+ pairs.push({ bone, relativeTo });
84
+ }
85
+ return pairs;
86
+ }
87
+
88
+ /** One sampled frame: its index, its time, and every bone's world transform. */
89
+ export interface AngleFrame {
90
+ index: number;
91
+ time: number;
92
+ bones: readonly BoneSnapshot[];
93
+ }
94
+
95
+ /** One extreme of a range: the angle, degrees, and the frame that reached it first. */
96
+ export interface AngleExtreme {
97
+ degrees: number;
98
+ index: number;
99
+ time: number;
100
+ }
101
+
102
+ /** One pair's reading over one set. */
103
+ export interface AngleRangeReading {
104
+ bone: string;
105
+ relativeTo: string;
106
+ /** Frames read — every frame of the set. */
107
+ frames: number;
108
+ low: AngleExtreme;
109
+ high: AngleExtreme;
110
+ }
111
+
112
+ /**
113
+ * Wrong readings the suite plants to see its controls go red (issue #1414):
114
+ * `worldAngle` reads the bone's own world direction and ignores the bone it is
115
+ * read against; `noFollow` leaves each frame's difference in (−180, 180] rather
116
+ * than following it from the frame before.
117
+ */
118
+ export interface AnglePlant {
119
+ worldAngle?: true;
120
+ noFollow?: true;
121
+ }
122
+
123
+ /** `d` taken into (−180, 180]. */
124
+ function wrapped(d: number): number {
125
+ let r = d % 360;
126
+ if (r <= -180) r += 360;
127
+ if (r > 180) r -= 360;
128
+ return r;
129
+ }
130
+
131
+ /** A world matrix with no direction — every entry zero: an unposed bone, or one scaled to nothing. */
132
+ function directionless(b: BoneSnapshot): boolean {
133
+ return b.a === 0 && b.b === 0 && b.c === 0 && b.d === 0;
134
+ }
135
+
136
+ /**
137
+ * Each pair's range over `frames`, in the pairs' order — refused by
138
+ * `AngleRangeError`, naming the bone and the frame, where a bone has no
139
+ * direction (`directionless`), and naming the bone where a frame carries no
140
+ * snapshot of it at all (frames sampled without `{ bones: true }`).
141
+ */
142
+ export function angleRangesOf(pairs: readonly BonePair[], frames: readonly AngleFrame[], where: string, plant: AnglePlant = {}): AngleRangeReading[] {
143
+ return pairs.map(({ bone, relativeTo }) => {
144
+ let previous: number | null = null;
145
+ let low: AngleExtreme | null = null;
146
+ let high: AngleExtreme | null = null;
147
+ for (const frame of frames) {
148
+ const at = (name: string): BoneSnapshot => {
149
+ const found = frame.bones.find((b) => b.name === name);
150
+ if (found === undefined) throw new AngleRangeError(`--angle-range, ${where} frame ${frame.index}: no world transform for bone ${JSON.stringify(name)} was sampled`);
151
+ if (directionless(found)) {
152
+ throw new AngleRangeError(
153
+ `--angle-range, ${where} frame ${frame.index} (t=${frame.time.toFixed(4)}s): bone ${JSON.stringify(name)} has no direction — its world matrix is zero ` +
154
+ '(a bone the posed skin leaves unposed, or one scaled to nothing), so it has no angle to read',
155
+ );
156
+ }
157
+ return found;
158
+ };
159
+ const a = at(bone);
160
+ const b = at(relativeTo);
161
+ const difference = plant.worldAngle === true ? a.rotationX : a.rotationX - b.rotationX;
162
+ let angle = wrapped(difference);
163
+ if (previous !== null && plant.noFollow !== true) angle += 360 * Math.round((previous - angle) / 360);
164
+ previous = angle;
165
+ if (low === null || angle < low.degrees) low = { degrees: angle, index: frame.index, time: frame.time };
166
+ if (high === null || angle > high.degrees) high = { degrees: angle, index: frame.index, time: frame.time };
167
+ }
168
+ if (low === null || high === null) throw new AngleRangeError(`--angle-range, ${where}: no frame was sampled`);
169
+ return { bone, relativeTo, frames: frames.length, low, high };
170
+ });
171
+ }
172
+
173
+ /** Degrees to four places, with a rounding to zero printed as `0.0000` rather than `-0.0000`. */
174
+ export function degreesText(d: number): string {
175
+ const text = d.toFixed(4);
176
+ return text === '-0.0000' ? '0.0000' : text;
177
+ }
178
+
179
+ /**
180
+ * The printed line: the set, the pair, the low and the high with the frame each
181
+ * was first reached on, the span, and the grid it was read on.
182
+ */
183
+ export function angleRangeLine(animation: string | null, fps: number, r: AngleRangeReading): string {
184
+ const where = animation === null ? 'the setup pose' : JSON.stringify(animation);
185
+ return (
186
+ `${where}: bone ${JSON.stringify(r.bone)} against ${JSON.stringify(r.relativeTo)} ranges ${degreesText(r.low.degrees)}° to ${degreesText(r.high.degrees)}° ` +
187
+ `(a span of ${degreesText(r.high.degrees - r.low.degrees)}°) over ${r.frames} frame(s) at ${fps} fps; ` +
188
+ `lowest at frame ${r.low.index} (t=${r.low.time.toFixed(4)}s), highest at frame ${r.high.index} (t=${r.high.time.toFixed(4)}s)`
189
+ );
190
+ }
@@ -21,7 +21,8 @@ import { chainFitLines, type ChainFitOptions, estimateChainFit } from '../chainf
21
21
  import { checkLines, CheckPlates } from '../check.ts';
22
22
  import { writeCheckPictures } from '../checkpics.ts';
23
23
  import { compile, type CompileOptions, droppedStateReason, relativeImagesPath } from '../compile.ts';
24
- import { CoreInputError } from '../core/index.ts';
24
+ import { CoreInputError, readModel } from '../core/index.ts';
25
+ import { leverFractionWhy, leverHeader, leverLines, leverReading, type LeverReading, type WhirlAnimation, WHIRL_HEADER, whirlLines, whirlReading } from '../whirl.ts';
25
26
  import { surveyOfBuild } from '../deformbuild.ts';
26
27
  import type { DeformSurvey } from '../deformsurvey.ts';
27
28
  import { deformReportBlock } from '../deformreport.ts';
@@ -32,10 +33,12 @@ import { MODEL_DOCUMENT_FILE, modelDocument } from '../model.ts';
32
33
  import { parseMotionSpec } from '../motion.ts';
33
34
  import { type InspectCompare, type InspectFrame, InspectError, inspectCandidate, INSPECT_DEFAULT_FPS, legendText, readBonePatterns, readShow, readTimes, readZoom } from '../inspect.ts';
34
35
  import { FOLDS_FILE, foldLine, foldsFileOf, foldsOf, foldsText, setupFaultLine } from '../folds.ts';
36
+ import { AngleRangeError, angleRangeLine, type AngleRangeReading, angleRangesOf, type BonePair, readBonePairs } from '../anglerange.ts';
37
+ import { outsideLine, outsideViewport, pinnedRecord, readViewportBox, viewportOfFramesFile, ViewportError } from '../viewportpin.ts';
35
38
  import { PASS_THROUGH_FILE, passFrameLine, type PassPair, passSummaryLine, passThroughFileOf, passThroughOf, passThroughText, PassThroughError, readPassPairs } from '../passthrough.ts';
36
39
  import { environmentCollisions, environmentFrame, EnvironmentError, environmentResponse, readEnvironmentSchedule, type EnvironmentStep } from '../environment.ts';
37
40
  import { estimatePose, poseLines, type PoseOptions } from '../pose.ts';
38
- import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser } from '../render_shared.ts';
41
+ import { BACKGROUND, contactSheet, type Frame, FRAMES_SIDECAR, FRAMES_SPEC, frameSetDir, type FrameSet, type FramesSidecar, framingViewport, GEOMETRY_FILE, geometryFileOf, geometryText, loadCandidate, POSER_NAMES, type PoserName, PROTOCOL_FPS, refuseUnchosen, renderFrame, sampleAll, sampleAnimation, SETUP_POSE_DIR, SHEET_FILE, SHEET_TILE, sidecarViewport, type SkeletonFacts, type SlotSubset, SlotSubsetError, throughPoser, type Viewport, viewportFor } from '../render_shared.ts';
39
42
  import { type CompileResult } from '../types.ts';
40
43
  import { attachmentRegionJoins } from '../region_joins.ts';
41
44
  import { cmdRepack } from './repack.ts';
@@ -556,6 +559,59 @@ function readPassThroughFlag(raw: string | undefined, facts: SkeletonFacts): Pas
556
559
  }
557
560
  }
558
561
 
562
+ /**
563
+ * `--angle-range`'s pairs, read against the skeleton's bones (issue #1414,
564
+ * `../anglerange.ts`), or refused as a usage error before anything is posed: a
565
+ * malformed list, an empty name, a bone the skeleton does not have, a bone read
566
+ * against itself, a pair named twice — each by its entry. `undefined` without the flag.
567
+ */
568
+ function readAngleRangeFlag(raw: string | undefined, facts: SkeletonFacts): BonePair[] | undefined {
569
+ if (raw === undefined) return undefined;
570
+ try {
571
+ return readBonePairs(raw, facts.bones.map((bone) => bone.name));
572
+ } catch (err) {
573
+ if (err instanceof AngleRangeError) throw new UsageError(err.message);
574
+ throw err;
575
+ }
576
+ }
577
+
578
+ /** A box `render` was given rather than fitting one (issue #1414): the box flag's numbers, or an earlier render's grid. */
579
+ type GivenViewport =
580
+ | { by: '--viewport'; box: { x: number; y: number; width: number; height: number } }
581
+ | { by: '--viewport-from'; path: string; viewport: Viewport };
582
+
583
+ /**
584
+ * `--viewport` and `--viewport-from`, read before anything is read off the
585
+ * candidate (`../viewportpin.ts`): the two together are refused, and so is
586
+ * `--max` beside `--viewport-from`, whose file already fixes the pixel size; a
587
+ * malformed box, a path with no `frames.json` and a file whose grid is not one
588
+ * are refused by name. `undefined` without either flag.
589
+ */
590
+ function readGivenViewport(flags: Record<string, string>): GivenViewport | undefined {
591
+ const box = flags.viewport;
592
+ const from = flags['viewport-from'];
593
+ if (box !== undefined && from !== undefined) {
594
+ throw new UsageError('--viewport and --viewport-from both give the box the frames are of; give one of them');
595
+ }
596
+ try {
597
+ if (box !== undefined) return { by: '--viewport', box: readViewportBox(box) };
598
+ if (from === undefined) return undefined;
599
+ if (flags.max !== undefined) {
600
+ throw new UsageError(
601
+ `--viewport-from takes no --max: the frames.json it reads fixes the pixel size as well as the box, so --max ${flags.max} ` +
602
+ 'would put this render on another grid. Give --viewport <x,y,w,h> with --max for a box at another size',
603
+ );
604
+ }
605
+ const given = resolve(from);
606
+ const path = existsSync(given) && statSync(given).isDirectory() ? join(given, FRAMES_SIDECAR) : given;
607
+ if (!existsSync(path)) throw new UsageError(`--viewport-from ${from}: no ${FRAMES_SIDECAR} at ${path} — it takes an earlier render's ${FRAMES_SIDECAR}, or the directory \`render --out\` wrote it into`);
608
+ return { by: '--viewport-from', path, viewport: viewportOfFramesFile(readJsonFile(path), path) };
609
+ } catch (err) {
610
+ if (err instanceof ViewportError) throw new UsageError(err.message);
611
+ throw err;
612
+ }
613
+ }
614
+
559
615
  /**
560
616
  * render — the frame series, drawn by the same rasteriser `check` measures with.
561
617
  *
@@ -574,6 +630,8 @@ export function cmdRender(flags: Record<string, string>): void {
574
630
  // Read before anything is read off the candidate, so a malformed schedule is refused before a line is printed.
575
631
  const environment = readEnvironmentFlag(flags);
576
632
  const maxSide = readPositiveNumber(flags, 'max', 256, 16);
633
+ // A box given rather than fitted (issue #1414), read before a line is printed, so a malformed one is refused first.
634
+ const given = readGivenViewport(flags);
577
635
  const outRoot = resolve(flags.out ?? 'render');
578
636
  const geometry = flags.geometry !== undefined;
579
637
  // The fold reading (issue #1383) is its own flag rather than a part of --geometry, so a caller that asked for
@@ -643,6 +701,8 @@ export function cmdRender(flags: Record<string, string>): void {
643
701
  const skin = readSkinFlag(flags, [...facts.skins]);
644
702
  const subset = readSlotSubsetFlags(flags, facts, skin);
645
703
  const passPairs = readPassThroughFlag(passThrough, facts);
704
+ // The angle reading (issue #1414) reads bones, which a subset of the drawn slots does not change, so it takes --slot/--hide.
705
+ const anglePairs = readAngleRangeFlag(flags['angle-range'], facts);
646
706
  // One object, so the framing and the frames cannot be posed under two
647
707
  // different skins — which would frame one shot with another shot's box.
648
708
  // Not annotated `PoseOptions`: that name is `src/pose.ts`'s in this file, and
@@ -686,14 +746,22 @@ export function cmdRender(flags: Record<string, string>): void {
686
746
  // sits at one point — every drawn bone unposed by the skin, or collapsed — as
687
747
  // an `UnframeablePoseError` (issue #997); neither reaches this. The roster is what
688
748
  // lets the second name the skins that pose a bone, whichever poser draws.
689
- const viewport = framingViewport(poser, maxSide, pose, roster);
690
- if (!viewport) {
749
+ const fitted = framingViewport(poser, maxSide, pose, roster);
750
+ if (!fitted) {
691
751
  throw new UsageError(
692
752
  `${skeletonPath} posed no drawable attachment in any animation or in its setup pose${
693
753
  skin === undefined ? '' : ` under skin ${JSON.stringify(skin)}`
694
754
  } — there is nothing to draw`,
695
755
  );
696
756
  }
757
+ // ⭐ A given box replaces the fitted one, and the framing still runs: it is what refuses a pose with nothing to draw,
758
+ // one that is not finite and one with no extent, and a box given over such a pose would draw it in silence.
759
+ const viewport =
760
+ given === undefined
761
+ ? fitted
762
+ : given.by === '--viewport'
763
+ ? viewportFor(given.box.x, given.box.y, given.box.x + given.box.width, given.box.y + given.box.height, maxSide)
764
+ : given.viewport;
697
765
 
698
766
  // `sampleAll` covers the skeleton with no animation at all, which files its one
699
767
  // setup-pose frame under the reserved name. Narrowing to one animation reuses
@@ -702,7 +770,10 @@ export function cmdRender(flags: Record<string, string>): void {
702
770
  // `--geometry` rides on the SAME call (issue #864): the bones and the whole
703
771
  // attachments are read off the skeleton at the step that drew each frame, so
704
772
  // the export's grid is this frame set's by construction.
705
- const sampling = geometry ? { ...pose, bones: true, geometry: true } : folds || passPairs !== undefined ? { ...pose, geometry: true } : pose;
773
+ // The bones ride along for --geometry and --angle-range, the whole attachments for --geometry and the two readings off them.
774
+ const wantsBones = geometry || anglePairs !== undefined;
775
+ const wantsGeometry = geometry || folds || passPairs !== undefined;
776
+ const sampling = !wantsBones && !wantsGeometry ? pose : { ...pose, ...(wantsBones ? { bones: true } : {}), ...(wantsGeometry ? { geometry: true } : {}) };
706
777
  const sampled: Map<string, Frame[]> =
707
778
  only === undefined
708
779
  ? sampleAll(poser, fps, sampling)
@@ -755,9 +826,22 @@ export function cmdRender(flags: Record<string, string>): void {
755
826
  }
756
827
  }
757
828
  }
758
- return { viewport, sampled, geometryTexts, foldReadings, passReadings };
829
+ // The angle reading, off the very bones the frames were posed with — one per set, every pair the flag names.
830
+ const angleReadings = new Map<string, AngleRangeReading[]>();
831
+ if (anglePairs !== undefined) {
832
+ for (const [name, frames] of sampled) {
833
+ const read = frames.map((frame) => ({ index: frame.index, time: frame.time, bones: frame.bones ?? [] }));
834
+ try {
835
+ angleReadings.set(name, angleRangesOf(anglePairs, read, JSON.stringify(name)));
836
+ } catch (err) {
837
+ if (err instanceof AngleRangeError) throw new UsageError(err.message);
838
+ throw err;
839
+ }
840
+ }
841
+ }
842
+ return { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings };
759
843
  });
760
- const { viewport, sampled, geometryTexts, foldReadings, passReadings } = posed.value;
844
+ const { viewport, fitted, sampled, geometryTexts, foldReadings, passReadings, angleReadings } = posed.value;
761
845
  console.log(` .. poser ${posed.note}`);
762
846
  if (environment !== undefined) {
763
847
  const response = environmentResponse(skeletonText);
@@ -768,6 +852,17 @@ export function cmdRender(flags: Record<string, string>): void {
768
852
  }
769
853
  console.log(` .. ${viewport.width}x${viewport.height}px at ${fps} fps, ${sampled.size} set(s) -> ${outRoot}`);
770
854
  if (!facts.declaresStage) console.log(` .. ${STAGELESS_FRAMING.render}`);
855
+ // A given box (issue #1414): said with the box it replaced, and every slot it leaves part of out, by set and frame.
856
+ const outside = given === undefined ? [] : outsideViewport(sampled, viewport);
857
+ if (given !== undefined) {
858
+ const f = (n: number): string => String(Number(n.toFixed(4)));
859
+ const box = (v: Viewport): string => `world x ${f(v.minX)}..${f(v.maxX)}, y ${f(v.minY)}..${f(v.maxY)}`;
860
+ console.log(
861
+ ` .. viewport given by ${given.by}${given.by === '--viewport-from' ? ` ${given.path}` : ''}: ${box(viewport)} at ${f(viewport.scale)} px per unit, y up ` +
862
+ `(fitted, the box would be ${box(fitted)}) — ${outside.length === 0 ? 'every drawn piece of every frame is inside it' : `${outside.length} slot(s) reach past it, named below`}`,
863
+ );
864
+ for (const reading of outside) console.log(` .. outside ${outsideLine(reading, fps)}`);
865
+ }
771
866
 
772
867
  mkdirSync(outRoot, { recursive: true });
773
868
  const sets: FrameSet[] = [];
@@ -776,7 +871,7 @@ export function cmdRender(flags: Record<string, string>): void {
776
871
  for (const [name, frames] of sampled) {
777
872
  // Same naming as a reference render: the protocol rate says nothing, any
778
873
  // other rate says itself, so two rates of one animation sit side by side.
779
- const dirName = fps === PROTOCOL_FPS ? name : `${name}@${fps}fps`;
874
+ const dirName = frameSetDir(name, fps);
780
875
  const dir = join(outRoot, dirName);
781
876
  // Cleared rather than written over: a shorter animation would otherwise leave
782
877
  // the tail of a longer previous run on disk, and stale frames in a frame set
@@ -825,6 +920,9 @@ export function cmdRender(flags: Record<string, string>): void {
825
920
  console.log(` .. pass ${passSummaryLine(passReading?.animation ?? null, fps, pair)}`);
826
921
  for (const frame of pair.frames) console.log(` .. pass ${passFrameLine(passReading?.animation ?? null, pair, frame)}`);
827
922
  }
923
+ for (const reading of angleReadings.get(name) ?? []) {
924
+ console.log(` .. angle ${angleRangeLine(name === SETUP_POSE_DIR && facts.animations.length === 0 ? null : name, fps, reading)}`);
925
+ }
828
926
  }
829
927
 
830
928
  // The sidecar is what makes this a frame SET rather than a pile of pictures:
@@ -842,6 +940,8 @@ export function cmdRender(flags: Record<string, string>): void {
842
940
  ...subsetFields(subset),
843
941
  // Written only when a schedule was given (issue #1416): a render without one says nothing and stays the bytes it was.
844
942
  ...(environment === undefined ? {} : { environment: environment.steps }),
943
+ // Written only when the box was given (issue #1414): a fitted render says nothing and stays the bytes it was.
944
+ ...(given === undefined ? {} : { pinned: pinnedRecord(given.by, outside) }),
845
945
  background: BACKGROUND,
846
946
  // The one spelling `geometry.json` repeats, so the two files cannot state
847
947
  // two boxes for one grid.
@@ -895,12 +995,12 @@ export function cmdInspect(flags: Record<string, string>): void {
895
995
  let frame: InspectFrame = { kind: 'shot' };
896
996
  if (flags.zoom !== undefined) frame = { kind: 'zoom', ...readZoom(flags.zoom) };
897
997
  if (flags.viewport !== undefined) {
898
- const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
899
- if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
900
- throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
998
+ try {
999
+ frame = { kind: 'viewport', ...readViewportBox(flags.viewport) };
1000
+ } catch (err) {
1001
+ if (err instanceof ViewportError) throw new UsageError(err.message);
1002
+ throw err;
901
1003
  }
902
- if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
903
- frame = { kind: 'viewport', x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
904
1004
  }
905
1005
  const target = resolve(flags.out ?? DEFAULT_INSPECT_OUT);
906
1006
  const out = existsSync(target) && statSync(target).isDirectory() ? join(target, DEFAULT_INSPECT_OUT) : target;
@@ -1176,6 +1276,11 @@ function refuseUnposableArt(result: CompileResult, opts: CompileOptions): void {
1176
1276
  export function cmdExplain(flags: Record<string, string>): void {
1177
1277
  // `--poser` is refused by its spelling before anything compiles (issue #1019): it chooses the deform survey's reader and poser.
1178
1278
  const poser = readPoserFlag(flags);
1279
+ // `--lever-fold` is refused by its value before anything compiles, too (issue #1427): the fraction is the caller's and
1280
+ // has no default, so a value that is not one is a usage error rather than a reading taken at a guess.
1281
+ const leverFold = flags['lever-fold'] === undefined ? null : Number(flags['lever-fold']);
1282
+ const leverWhy = leverFold === null ? null : leverFractionWhy(leverFold);
1283
+ if (leverWhy !== null) throw new UsageError(`--lever-fold ${JSON.stringify(flags['lever-fold'])}: ${leverWhy}`);
1179
1284
  const { label, opts } = resolveCut(flags);
1180
1285
  console.log(`rigc explain ${label}`);
1181
1286
  // See the identical pair of lines in `cmdBuild` for why both paths are named
@@ -1372,6 +1477,24 @@ export function cmdExplain(flags: Record<string, string>): void {
1372
1477
  console.log(` transform ${gen.kind} ${gen.stated}`);
1373
1478
  console.log(` ${gen.formula}`);
1374
1479
  for (const line of gen.derived) console.log(` ${line}`);
1480
+ // The envelope's weights, one per vertex, because they are half
1481
+ // of every number below (issue #1413): each offset is
1482
+ // round(w × the un-enveloped offset), and a reader holding these
1483
+ // and the model reproduces the run without a second evaluation.
1484
+ if (gen.envelope !== undefined) {
1485
+ const env = gen.envelope;
1486
+ console.log(` envelope ${env.stated}`);
1487
+ console.log(` ${env.formula}`);
1488
+ console.log(
1489
+ ` each offset below is round(w × the un-enveloped offset): ${env.zero} vertex(es) at w 0, ` +
1490
+ `${env.full} at 1, ${gen.vertexCount - env.zero - env.full} between`,
1491
+ );
1492
+ for (let v = 0; v < env.weights.length; v += 8) {
1493
+ const ws: string[] = [];
1494
+ for (let k = v; k < Math.min(v + 8, env.weights.length); k++) ws.push(`v${String(k).padStart(3)} ${env.weights[k]}`);
1495
+ console.log(` w ${ws.join(' ')}`);
1496
+ }
1497
+ }
1375
1498
  console.log(
1376
1499
  ` ${gen.vertexCount} vertices, largest offset ${gen.maxOffset}px at vertex ${gen.maxOffsetVertex}`,
1377
1500
  );
@@ -1436,6 +1559,36 @@ export function cmdExplain(flags: Record<string, string>): void {
1436
1559
  for (const ph of result.physics) {
1437
1560
  console.log(` ${ph.name.padEnd(12)} bone=${ph.bone.padEnd(14)} components=[${ph.components.join(', ')}] mix=${ph.mix} drivesMesh=${ph.drivesMesh}`);
1438
1561
  }
1562
+ // The whirl reading (issue #1423): how far each constraint's bone turns in one step, walked by the core from the
1563
+ // setup pose — `src/whirl.ts` says why that walk and not render's. It decides nothing, so it sits in the report
1564
+ // rather than in the gate, and explain's exit is the same whatever it prints.
1565
+ console.log(`\n${WHIRL_HEADER}`);
1566
+ let reading: WhirlAnimation[] | null = null;
1567
+ try {
1568
+ reading = whirlReading(readModel(modelDocument(result.model, result.skeletonText, result.atlasText), `the whirl reading of ${label}`));
1569
+ } catch (err) {
1570
+ if (!(err instanceof CoreInputError)) throw err;
1571
+ console.log(` .. turn not read — the core refused the model document: ${err.message}`);
1572
+ }
1573
+ if (reading !== null && reading.length === 0) console.log(' .. turn not read — the skeleton declares no animation to walk');
1574
+ for (const line of whirlLines(reading ?? [])) console.log(` .. turn ${line}`);
1575
+ // The lever reading (issue #1427): on the same walk, the steps a scaleX drive's inertia lever divides by a collapsed
1576
+ // divisor, and the steps its scale factor jumps. Only when asked for, because its fraction has no default —
1577
+ // and said so when not asked, rather than left out in silence.
1578
+ if (leverFold === null) {
1579
+ console.log('\n .. lever not read — `--lever-fold <fraction>` states the fraction of its setup divisor a scaleX lever is named for dividing below, and it has no default');
1580
+ } else {
1581
+ console.log(`\n${leverHeader(leverFold)}`);
1582
+ let levers: LeverReading | null = null;
1583
+ try {
1584
+ levers = leverReading(readModel(modelDocument(result.model, result.skeletonText, result.atlasText), `the lever reading of ${label}`), leverFold);
1585
+ } catch (err) {
1586
+ if (!(err instanceof CoreInputError)) throw err;
1587
+ console.log(` .. lever not read — the core refused the model document: ${err.message}`);
1588
+ }
1589
+ if (levers !== null && levers.animations.length === 0 && levers.unread.length < result.physics.length) console.log(' .. lever not read — the skeleton declares no animation to walk');
1590
+ for (const line of levers === null ? [] : leverLines(levers)) console.log(` .. lever ${line}`);
1591
+ }
1439
1592
  }
1440
1593
 
1441
1594
  // Path constraints, with the curve each one follows MEASURED — its length and
package/src/cli/shared.ts CHANGED
@@ -39,6 +39,7 @@ import { compile, CompileError, droppedStateReason, headerBoundsOf, type Compile
39
39
  import { depthStepLevels, type FoldLimit, type TurnCeiling } from '../depth.ts';
40
40
  import { FOLDS_FILE } from '../folds.ts';
41
41
  import { PASS_THROUGH_FILE, PASS_THROUGH_RUN } from '../passthrough.ts';
42
+ import { readViewportBox, ViewportError } from '../viewportpin.ts';
42
43
  import { parseJsonWithPosition } from '../json-position.ts';
43
44
  import { RUNG_IDS } from '../ladder.ts';
44
45
  import { MODEL_DOCUMENT_FILE, MODEL_DOCUMENT_SPEC, modelDocument, spineFileSha256 } from '../model.ts';
@@ -675,12 +676,12 @@ function readCheckFlags(
675
676
  out.fps = fps;
676
677
  }
677
678
  if (flags.viewport !== undefined) {
678
- const parts = flags.viewport.split(',').map((s) => Number(s.trim()));
679
- if (parts.length !== 4 || parts.some((n) => !Number.isFinite(n))) {
680
- throw new UsageError('--viewport takes four numbers: <x>,<y>,<width>,<height> — the world box, y up');
679
+ try {
680
+ out.viewport = readViewportBox(flags.viewport);
681
+ } catch (err) {
682
+ if (err instanceof ViewportError) throw new UsageError(err.message);
683
+ throw err;
681
684
  }
682
- if (parts[2] <= 0 || parts[3] <= 0) throw new UsageError('--viewport width and height must be positive');
683
- out.viewport = { x: parts[0], y: parts[1], width: parts[2], height: parts[3] };
684
685
  }
685
686
  if (flags.as !== undefined) out.as = flags.as;
686
687
  // The name is not checked against the candidate here: `check` owns that
@@ -1021,6 +1022,17 @@ const FLAG_MEANINGS: Record<string, string> = {
1021
1022
  'world units, one texel of the coarser drawing to a step — each with the depth and the world box. One line per pair, and one per frame that passes through. ' +
1022
1023
  'The order is the declaration: the first name must draw over the second for a frame to be read. A reading, not a gate. ' +
1023
1024
  'An unknown slot, a slot that draws on no frame, a malformed list and a pair named twice are refused by name. Not with --slot/--hide',
1025
+ 'angle-range':
1026
+ 'also read, for each <bone>:<relative-to> pair, the range of the first bone\'s world direction against the second\'s over each set\'s ' +
1027
+ 'frames — its rotationX less the other\'s, followed from frame to frame so a sweep past ±180° reads as itself — and print one line per ' +
1028
+ 'pair per set: the lowest and the highest angle in degrees, the span, and the frame each was first reached on. Read on the frames\' own ' +
1029
+ 'grid, so a peak between two frames is not read. A reading, not a gate: nothing is written. An unknown bone, a bone against itself, ' +
1030
+ 'a malformed list and a pair named twice are refused by name, and so is a bone with no direction on a frame (a world matrix of zero)',
1031
+ 'viewport-from':
1032
+ "draw on an earlier render's grid exactly — the box, the scale and the pixel size its frames.json records (the file, or the " +
1033
+ 'directory `render --out` wrote it into) — so a world point lands on the same pixel in both renders. A slot that reaches past ' +
1034
+ 'the box is named as under --viewport, and frames.json records the same under `pinned`. Not with --viewport, and not with --max: ' +
1035
+ 'the file fixes the pixel size',
1024
1036
  environment:
1025
1037
  'the world the rig\'s physics answers, in time: a file { "spec": "rigc-environment/1", "steps": [{ "t": <seconds>, ' +
1026
1038
  '"wind"?: [x, y], "gravity"?: [x, y] }, …] }, x right and y down, each step holding from its t until the next and set ' +
@@ -1035,6 +1047,12 @@ const FLAG_MEANINGS: Record<string, string> = {
1035
1047
  'line of the render or the check report says which and why. `--poser core` on an input that cannot carry it is refused by name. ' +
1036
1048
  "`explain`: which reads and poses the DEFORM block's survey — `core` (the model document the compile writes, spine-core untouched) or " +
1037
1049
  '`spine` (the Spine skeleton parsed and posed by spine-core); default `core`, and `spine` where the core refuses the document, which the block names',
1050
+ 'lever-fold':
1051
+ '`explain` only: also read, on the turn lines\' walk, the steps where a physics constraint that drives scaleX has its inertia lever ' +
1052
+ 'divide by less than this fraction (above 0, at most 1) of the divisor it takes at the setup pose — the bone\'s length times its world x-axis ' +
1053
+ 'length, less scaleLag × shown on a drive that does not rotate — and the steps where the scale factor it applies moves by more than the ' +
1054
+ 'bone\'s setup scale in one step: each line the constraint, the steps, the extreme value and its step. No default: without it the reading ' +
1055
+ 'is not taken, and says so; a value that is not such a fraction is refused by name. A reading, not a gate',
1038
1056
  'texture-from':
1039
1057
  "also measure this run through this atlas's texels, keeping the candidate's own geometry, and report how much " +
1040
1058
  'of the MAE is texture resampling rather than the rig — pass the atlas the reference frames were rendered ' +
@@ -1211,6 +1229,8 @@ const FLAG_VALUES: Record<string, string> = {
1211
1229
  poser: 'core|spine',
1212
1230
  environment: '<schedule.json>',
1213
1231
  'pass-through': '<cloth>:<leg>[,<cloth>:<leg>…]',
1232
+ 'angle-range': '<bone>:<relative-to>[,…]',
1233
+ 'viewport-from': '<frames.json|dir>',
1214
1234
  reference: '<dir|skeleton.json>',
1215
1235
  'reference-atlas': '<path>',
1216
1236
  bones: `<correspondence.json|${IDENTITY_CORRESPONDENCE}>`,
@@ -1225,6 +1245,7 @@ const FLAG_VALUES: Record<string, string> = {
1225
1245
  scale: '<min,max>',
1226
1246
  rotation: '<min,max>',
1227
1247
  'max-residual': '<0..1>',
1248
+ 'lever-fold': '<fraction>',
1228
1249
  anchor: '<pose.json>',
1229
1250
  hinge: '<min,max>',
1230
1251
  stretch: '<ratio>',
@@ -1476,8 +1497,9 @@ export const COMMANDS: CommandDoc[] = [
1476
1497
  'rigc explain --rig <path> --motion <path> --out <dir> [--manifest <path>] [--images <dir>] [--poser core|spine] (it never gates, and writes nothing)',
1477
1498
  'rigc explain … --atlas-in <skeleton.atlas> (resolve the parts against a pack somebody already made, as build does)',
1478
1499
  'rigc explain --cut <name> --cuts <cuts.json>',
1500
+ 'rigc explain … --lever-fold <fraction> (also name the steps a scaleX lever divides by a collapsed length)',
1479
1501
  ],
1480
- flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser'],
1502
+ flags: ['rig', 'motion', 'out', 'manifest', 'images', 'atlas-in', 'cut', 'cuts', 'poser', 'lever-fold'],
1481
1503
  notes: [
1482
1504
  'this line said "the same arguments as build, minus --profile" and was false in both',
1483
1505
  'directions (issue #697): --atlas-in was not listed here, so the one flag that lets this',
@@ -1618,11 +1640,24 @@ export const COMMANDS: CommandDoc[] = [
1618
1640
  'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
1619
1641
  'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
1620
1642
  'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
1643
+ 'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
1644
+ 'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
1621
1645
  ],
1622
- flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'geometry', 'folds', 'pass-through', 'environment', 'poser', 'out'],
1646
+ flags: ['candidate', 'atlas', 'animation', 'skin', 'slot', 'hide', 'fps', 'max', 'viewport', 'viewport-from', 'geometry', 'folds', 'pass-through', 'angle-range', 'environment', 'poser', 'out'],
1623
1647
  overrides: {
1624
1648
  out: { value: '<dir>', meaning: 'directory to write the frame series into (default `render/`)' },
1625
- fps: { meaning: `frames per second to sample the animation at (default ${PROTOCOL_FPS})` },
1649
+ fps: {
1650
+ meaning:
1651
+ `frames per second to sample the animation at (default ${PROTOCOL_FPS}). A set drawn at any rate but the default is written to ` +
1652
+ '`<anim>@<fps>fps/` rather than `<anim>/`, so two rates sit side by side: `--fps 24` writes `<anim>@24fps/`, and the default 12 writes `<anim>/`',
1653
+ },
1654
+ viewport: {
1655
+ meaning:
1656
+ 'draw every frame over this world box, y up — x,y its bottom-left corner, the numbers frames.json\'s `viewport` records — ' +
1657
+ 'instead of the box fitted to the rig; its pixel size follows from --max. A slot that reaches past the box is named by set and ' +
1658
+ 'frame, with how far and on which side: what lies past it is not drawn, since each frame is the box. frames.json records that ' +
1659
+ 'the box was given, and those slots, under `pinned`. Not with --viewport-from',
1660
+ },
1626
1661
  },
1627
1662
  },
1628
1663
  {